Commit caab23bc authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '33905-connect-app-and-vuex' into 'master'

Finalise vue packages list app

See merge request gitlab-org/gitlab!20160
parents 4b6941e0 045e7585
...@@ -15,6 +15,7 @@ export default { ...@@ -15,6 +15,7 @@ export default {
'/:project_full_path/environments/:environment_id/pods/:pod_name/containers/logs.json', '/:project_full_path/environments/:environment_id/pods/:pod_name/containers/logs.json',
podLogsPathWithPodContainer: podLogsPathWithPodContainer:
'/:project_full_path/environments/:environment_id/pods/:pod_name/containers/:container_name/logs.json', '/:project_full_path/environments/:environment_id/pods/:pod_name/containers/:container_name/logs.json',
groupPackagesPath: '/api/:version/groups/:id/packages',
projectPackagesPath: '/api/:version/projects/:id/packages', projectPackagesPath: '/api/:version/projects/:id/packages',
projectPackagePath: '/api/:version/projects/:id/packages/:package_id', projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
...@@ -109,9 +110,14 @@ export default { ...@@ -109,9 +110,14 @@ export default {
return axios.get(url); return axios.get(url);
}, },
projectPackages(id) { groupPackages(id, options = {}) {
const url = Api.buildUrl(this.groupPackagesPath).replace(':id', id);
return axios.get(url, options);
},
projectPackages(id, options = {}) {
const url = Api.buildUrl(this.projectPackagesPath).replace(':id', id); const url = Api.buildUrl(this.projectPackagesPath).replace(':id', id);
return axios.get(url); return axios.get(url, options);
}, },
buildProjectPackageUrl(projectId, packageId) { buildProjectPackageUrl(projectId, packageId) {
......
<script> <script>
import { mapState } from 'vuex';
import { GlTable, GlPagination, GlButton, GlSorting, GlSortingItem, GlModal } from '@gitlab/ui'; import { GlTable, GlPagination, GlButton, GlSorting, GlSortingItem, GlModal } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import {
LIST_KEY_NAME,
LIST_KEY_PROJECT,
LIST_KEY_VERSION,
LIST_KEY_PACKAGE_TYPE,
LIST_KEY_CREATED_AT,
LIST_KEY_ACTIONS,
LIST_LABEL_NAME,
LIST_LABEL_PROJECT,
LIST_LABEL_VERSION,
LIST_LABEL_PACKAGE_TYPE,
LIST_LABEL_CREATED_AT,
LIST_LABEL_ACTIONS,
} from '../constants';
export default { export default {
components: { components: {
...@@ -15,13 +30,6 @@ export default { ...@@ -15,13 +30,6 @@ export default {
GlModal, GlModal,
Icon, Icon,
}, },
props: {
canDestroyPackage: {
type: Boolean,
required: false,
default: false,
},
},
data() { data() {
return { return {
modalId: 'confirm-delete-pacakge', modalId: 'confirm-delete-pacakge',
...@@ -29,22 +37,20 @@ export default { ...@@ -29,22 +37,20 @@ export default {
}; };
}, },
computed: { computed: {
// the following computed properties are going to be connected to vuex ...mapState({
list() { list: 'packages',
return []; perPage: state => state.pagination.perPage,
}, totalItems: state => state.pagination.total,
perPage() { page: state => state.pagination.page,
return 20; canDestroyPackage: state => state.config.canDestroyPackage,
}, isGroupPage: state => state.config.isGroupPage,
totalItems() { }),
return 100;
},
currentPage: { currentPage: {
get() { get() {
return 1; return this.page;
}, },
set() { set(value) {
// do something with value this.$emit('page:changed', value);
}, },
}, },
orderBy() { orderBy() {
...@@ -68,36 +74,45 @@ export default { ...@@ -68,36 +74,45 @@ export default {
return this.canDestroyPackage; return this.canDestroyPackage;
}, },
sortableFields() { sortableFields() {
// This list is filtered in the case of the project page, and the project column is removed
return [ return [
{ {
key: 'name', key: LIST_KEY_NAME,
label: s__('Name'), label: LIST_LABEL_NAME,
tdClass: ['w-25'], class: ['text-left'],
},
{
key: LIST_KEY_PROJECT,
label: LIST_LABEL_PROJECT,
class: ['text-center'],
}, },
{ {
key: 'version', key: LIST_KEY_VERSION,
label: s__('Version'), label: LIST_LABEL_VERSION,
class: ['text-center'],
}, },
{ {
key: 'package_type', key: LIST_KEY_PACKAGE_TYPE,
label: s__('Type'), label: LIST_LABEL_PACKAGE_TYPE,
class: ['text-center'],
}, },
{ {
key: 'created_at', key: LIST_KEY_CREATED_AT,
label: s__('Created'), label: LIST_LABEL_CREATED_AT,
class: this.showActions ? ['text-center'] : ['text-right'],
}, },
]; ].filter(f => f.key !== LIST_KEY_PROJECT || this.isGroupPage);
}, },
headerFields() { headerFields() {
const actions = { const actions = {
key: 'actions', key: LIST_KEY_ACTIONS,
label: '', label: LIST_LABEL_ACTIONS,
tdClass: ['text-right'], tdClass: ['text-right'],
}; };
return this.showActions ? [...this.sortableFields, actions] : this.sortableFields; return this.showActions ? [...this.sortableFields, actions] : this.sortableFields;
}, },
modalAction() { modalAction() {
return s__('PackageRegistry|Delete Package'); return s__('PackageRegistry|Delete package');
}, },
deletePackageDescription() { deletePackageDescription() {
if (!this.itemToBeDeleted) { if (!this.itemToBeDeleted) {
...@@ -107,20 +122,24 @@ export default { ...@@ -107,20 +122,24 @@ export default {
s__( s__(
'PackageRegistry|You are about to delete <b>%{packageName}</b>, this operation is irreversible, are you sure?', 'PackageRegistry|You are about to delete <b>%{packageName}</b>, this operation is irreversible, are you sure?',
), ),
{ packageName: this.itemToBeDeleted.name }, { packageName: `${this.itemToBeDeleted.name}:${this.itemToBeDeleted.version}` },
false, false,
); );
}, },
}, },
methods: { methods: {
onDirectionChange() {}, onDirectionChange() {
onSortItemClick() {}, // to be connected to the sorting api when the api is ready
setItemToBeDeleted({ name, id }) { },
this.itemToBeDeleted = { name, id }; onSortItemClick() {
// to be connected to the sorting api when the api is ready
},
setItemToBeDeleted(item) {
this.itemToBeDeleted = { ...item };
this.$refs.packageListDeleteModal.show(); this.$refs.packageListDeleteModal.show();
}, },
deleteItemConfirmation() { deleteItemConfirmation() {
// this is going to be connected to vuex action this.$emit('package:delete', this.itemToBeDeleted.id);
this.itemToBeDeleted = null; this.itemToBeDeleted = null;
}, },
deleteItemCanceled() { deleteItemCanceled() {
...@@ -160,11 +179,17 @@ export default { ...@@ -160,11 +179,17 @@ export default {
> >
<template #name="{value}"> <template #name="{value}">
<div ref="col-name" class="flex-truncate-parent"> <div ref="col-name" class="flex-truncate-parent">
<a href="/asd/lol" class="flex-truncate-child" data-qa-selector="package_link"> <a href="#" class="flex-truncate-child" data-qa-selector="package_link">
{{ value }} {{ value }}
</a> </a>
</div> </div>
</template> </template>
<template #project="{value}">
<div ref="col-project" class="flex-truncate-parent">
<a :href="value" class="flex-truncate-child"> {{ value }} </a>
</div>
</template>
<template #version="{value}"> <template #version="{value}">
{{ value }} {{ value }}
</template> </template>
......
<script> <script>
import { GlEmptyState } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import PackageList from './packages_list.vue'; import PackageList from './packages_list.vue';
export default { export default {
components: { components: {
GlEmptyState, GlEmptyState,
GlLoadingIcon,
PackageList, PackageList,
}, },
props: {
projectId: {
type: String,
required: false,
default: '',
},
groupId: {
type: String,
required: false,
default: '',
},
canDestroyPackage: {
type: Boolean,
required: false,
default: false,
},
emptyListIllustration: {
type: String,
required: true,
},
emptyListHelpUrl: {
type: String,
required: true,
},
},
computed: { computed: {
...mapState({
isLoading: 'isLoading',
resourceId: state => state.config.resourceId,
emptyListIllustration: state => state.config.emptyListIllustration,
emptyListHelpUrl: state => state.config.emptyListHelpUrl,
}),
emptyListText() { emptyListText() {
return sprintf( return sprintf(
s__( s__(
...@@ -47,11 +30,24 @@ export default { ...@@ -47,11 +30,24 @@ export default {
); );
}, },
}, },
mounted() {
this.requestPackagesList();
},
methods: {
...mapActions(['requestPackagesList', 'requestDeletePackage']),
onPageChanged(page) {
return this.requestPackagesList({ page });
},
onPackageDeleteRequest(packageId) {
return this.requestDeletePackage({ projectId: this.resourceId, packageId });
},
},
}; };
</script> </script>
<template> <template>
<package-list :can-destroy-package="canDestroyPackage"> <gl-loading-icon v-if="isLoading" />
<package-list v-else @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state> <template #empty-state>
<gl-empty-state <gl-empty-state
:title="s__('PackageRegistry|There are no packages yet')" :title="s__('PackageRegistry|There are no packages yet')"
......
...@@ -5,3 +5,23 @@ export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __( ...@@ -5,3 +5,23 @@ export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __(
); );
export const FETCH_PACKAGE_ERROR_MESSAGE = __('Something went wrong while fetching the package.'); export const FETCH_PACKAGE_ERROR_MESSAGE = __('Something went wrong while fetching the package.');
export const DELETE_PACKAGE_ERROR_MESSAGE = __('Something went wrong while deleting the package.'); export const DELETE_PACKAGE_ERROR_MESSAGE = __('Something went wrong while deleting the package.');
export const DELETE_PACKAGE_SUCCESS_MESSAGE = __('Package deleted successfully');
export const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 20;
export const GROUP_PAGE_TYPE = 'groups';
export const LIST_KEY_NAME = 'name';
export const LIST_KEY_PROJECT = 'project';
export const LIST_KEY_VERSION = 'version';
export const LIST_KEY_PACKAGE_TYPE = 'package_type';
export const LIST_KEY_CREATED_AT = 'created_at';
export const LIST_KEY_ACTIONS = 'actions';
export const LIST_LABEL_NAME = __('Name');
export const LIST_LABEL_PROJECT = __('Project');
export const LIST_LABEL_VERSION = __('Version');
export const LIST_LABEL_PACKAGE_TYPE = __('Type');
export const LIST_LABEL_CREATED_AT = __('Created');
export const LIST_LABEL_ACTIONS = '';
import Vue from 'vue'; import Vue from 'vue';
import PackagesListApp from './components/packages_list_app.vue';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import { createStore } from './stores';
import PackagesListApp from './components/packages_list_app.vue';
Vue.use(Translate); Vue.use(Translate);
export default () => export default () => {
new Vue({ const el = document.getElementById('js-vue-packages-list');
el: '#js-vue-packages-list', const store = createStore();
store.dispatch('setInitialState', el.dataset);
return new Vue({
el,
store,
components: { components: {
PackagesListApp, PackagesListApp,
}, },
data() {
const {
dataset: { projectId, groupId, emptyListIllustration, emptyListHelpUrl, canDestroyPackage },
} = document.querySelector(this.$options.el);
return {
packageListAttrs: {
projectId,
groupId,
emptyListIllustration,
emptyListHelpUrl,
canDestroyPackage,
},
};
},
render(createElement) { render(createElement) {
return createElement('packages-list-app', { return createElement('packages-list-app');
props: {
...this.packageListAttrs,
},
});
}, },
}); });
};
import Api from '~/api'; import Api from 'ee/api';
import createFlash from '~/flash'; import createFlash from '~/flash';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { FETCH_PACKAGES_LIST_ERROR_MESSAGE, DELETE_PACKAGE_ERROR_MESSAGE } from '../constants'; import {
FETCH_PACKAGES_LIST_ERROR_MESSAGE,
DELETE_PACKAGE_ERROR_MESSAGE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
} from '../constants';
export const setProjectId = ({ commit }, data) => commit(types.SET_PROJECT_ID, data); export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
export const setUserCanDelete = ({ commit }, data) => commit(types.SET_USER_CAN_DELETE, data);
export const setLoading = ({ commit }, data) => commit(types.SET_MAIN_LOADING, data); export const setLoading = ({ commit }, data) => commit(types.SET_MAIN_LOADING, data);
export const receivePackagesListSuccess = ({ commit }, data) => export const receivePackagesListSuccess = ({ commit }, { data, headers }) => {
commit(types.SET_PACKAGE_LIST_SUCCESS, data); commit(types.SET_PACKAGE_LIST_SUCCESS, data);
commit(types.SET_PAGINATION, headers);
};
export const requestPackagesList = ({ dispatch, state }) => { export const requestPackagesList = ({ dispatch, state }, pagination = {}) => {
dispatch('setLoading', true); dispatch('setLoading', true);
const { page, perPage } = state.pagination; const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages';
return Api.projectPackages(state.projectId, { params: { page, perPage } }) return Api[apiMethod](state.config.resourceId, { params: { page, per_page: perPage } })
.then(({ data }) => { .then(({ data, headers }) => {
dispatch('receivePackagesListSuccess', data); dispatch('receivePackagesListSuccess', { data, headers });
}) })
.catch(() => { .catch(() => {
createFlash(FETCH_PACKAGES_LIST_ERROR_MESSAGE); createFlash(FETCH_PACKAGES_LIST_ERROR_MESSAGE);
...@@ -30,7 +37,10 @@ export const requestPackagesList = ({ dispatch, state }) => { ...@@ -30,7 +37,10 @@ export const requestPackagesList = ({ dispatch, state }) => {
export const requestDeletePackage = ({ dispatch }, { projectId, packageId }) => { export const requestDeletePackage = ({ dispatch }, { projectId, packageId }) => {
dispatch('setLoading', true); dispatch('setLoading', true);
return Api.deleteProjectPackage(projectId, packageId) return Api.deleteProjectPackage(projectId, packageId)
.then(() => dispatch('fetchPackages')) .then(() => {
dispatch('requestPackagesList');
createFlash(DELETE_PACKAGE_SUCCESS_MESSAGE, 'success');
})
.catch(() => { .catch(() => {
createFlash(DELETE_PACKAGE_ERROR_MESSAGE); createFlash(DELETE_PACKAGE_ERROR_MESSAGE);
}) })
......
export const SET_PROJECT_ID = 'SET_PROJECT_ID'; export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const SET_USER_CAN_DELETE = 'SET_USER_CAN_DELETE';
export const SET_PACKAGE_LIST = 'SET_PACKAGE_LIST'; export const SET_PACKAGE_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
import _ from 'underscore';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { GROUP_PAGE_TYPE } from '../constants';
export default { export default {
[types.SET_PROJECT_ID](state, projectId) { [types.SET_INITIAL_STATE](state, config) {
state.projectId = projectId; state.config = {
...config,
isGroupPage: config.pageType === GROUP_PAGE_TYPE,
canDestroyPackage: !(
_.isNull(config.canDestroyPackage) || _.isUndefined(config.canDestroyPackage)
),
};
}, },
[types.SET_USER_CAN_DELETE](state, userCanDelete) { [types.SET_PACKAGE_LIST_SUCCESS](state, packages) {
state.userCanDelete = userCanDelete;
},
[types.SET_PACKAGE_LIST](state, packages) {
state.packages = packages; state.packages = packages;
}, },
...@@ -18,7 +22,7 @@ export default { ...@@ -18,7 +22,7 @@ export default {
state.isLoading = isLoading; state.isLoading = isLoading;
}, },
[types.SET_PAGINATION](state, { headers }) { [types.SET_PAGINATION](state, headers) {
const normalizedHeaders = normalizeHeaders(headers); const normalizedHeaders = normalizeHeaders(headers);
state.pagination = parseIntPagination(normalizedHeaders); state.pagination = parseIntPagination(normalizedHeaders);
}, },
......
export default () => ({ export default () => ({
/**
* Determine if the component is loading data from the API
*/
isLoading: false, isLoading: false,
/** project id used to fetch data */ /**
projectId: null, * configuration object, set once at store creation with the following structure
userCanDelete: false, // controls the delete buttons in the list * {
* resourceId: String,
* pageType: String,
* userCanDelete: Boolean,
* emptyListIllustration: String,
* emptyListHelpUrl: String
* }
*/
config: {},
/** /**
* Each object in `packages` has the following structure: * Each object in `packages` has the following structure:
* { * {
...@@ -13,5 +24,13 @@ export default () => ({ ...@@ -13,5 +24,13 @@ export default () => ({
* } * }
*/ */
packages: [], packages: [],
/**
* Pagination object has the following structure:
* {
* perPage: Number,
* page: Number
* total: Number
* }
*/
pagination: {}, pagination: {},
}); });
import initPackageList from 'ee/packages/list/packages_list_app_bundle'; import initPackageList from 'ee/packages/list/packages_list_app_bundle';
document.addEventListener('DOMContentLoaded', initPackageList); document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('js-vue-packages-list')) {
initPackageList();
}
});
import initPackageList from 'ee/packages/list/packages_list_app_bundle'; import initPackageList from 'ee/packages/list/packages_list_app_bundle';
document.addEventListener('DOMContentLoaded', initPackageList); document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('js-vue-packages-list')) {
initPackageList();
}
});
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
- if vue_package_list_enabled_for?(@group) - if vue_package_list_enabled_for?(@group)
.row .row
.col-12 .col-12
#js-vue-packages-list{ data: { group_id: @group.id, #js-vue-packages-list{ data: { resource_id: @group.id,
page_type: 'groups',
empty_list_help_url: help_page_path('administration/packages/index'), empty_list_help_url: help_page_path('administration/packages/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg') } } empty_list_illustration: image_path('illustrations/no-packages.svg') } }
......
...@@ -4,7 +4,8 @@ ...@@ -4,7 +4,8 @@
- if vue_package_list_enabled_for?(@project) - if vue_package_list_enabled_for?(@project)
.row .row
.col-12 .col-12
#js-vue-packages-list{ data: { project_id: @project.id, #js-vue-packages-list{ data: { resource_id: @project.id,
page_type: 'projects',
can_destroy_package: can_destroy_package, can_destroy_package: can_destroy_package,
empty_list_help_url: help_page_path('administration/packages/index'), empty_list_help_url: help_page_path('administration/packages/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg') } } empty_list_illustration: image_path('illustrations/no-packages.svg') } }
......
...@@ -216,17 +216,32 @@ describe('Api', () => { ...@@ -216,17 +216,32 @@ describe('Api', () => {
describe('packages', () => { describe('packages', () => {
const projectId = 'project_a'; const projectId = 'project_a';
const packageId = 'package_b'; const packageId = 'package_b';
const apiResponse = [{ id: 1, name: 'foo' }];
describe('groupPackages', () => {
const groupId = 'group_a';
it('fetch all group packages', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/packages`;
jest.spyOn(axios, 'get');
mock.onGet(expectedUrl).replyOnce(200, apiResponse);
return Api.groupPackages(groupId).then(({ data }) => {
expect(data).toEqual(apiResponse);
expect(axios.get).toHaveBeenCalledWith(expectedUrl, {});
});
});
});
describe('projectPackages', () => { describe('projectPackages', () => {
it('fetch all project packages', () => { it('fetch all project packages', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/packages`; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/packages`;
const apiResponse = [{ id: 1, name: 'foo' }];
jest.spyOn(axios, 'get'); jest.spyOn(axios, 'get');
mock.onGet(expectedUrl).replyOnce(200, apiResponse); mock.onGet(expectedUrl).replyOnce(200, apiResponse);
return Api.projectPackages(projectId).then(({ data }) => { return Api.projectPackages(projectId).then(({ data }) => {
expect(data).toEqual(apiResponse); expect(data).toEqual(apiResponse);
expect(axios.get).toHaveBeenCalledWith(expectedUrl); expect(axios.get).toHaveBeenCalledWith(expectedUrl, {});
}); });
}); });
}); });
...@@ -242,8 +257,6 @@ describe('Api', () => { ...@@ -242,8 +257,6 @@ describe('Api', () => {
describe('projectPackage', () => { describe('projectPackage', () => {
it('fetch package details', () => { it('fetch package details', () => {
const expectedUrl = `foo`; const expectedUrl = `foo`;
const apiResponse = { id: 1, name: 'foo' };
jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl); jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl);
jest.spyOn(axios, 'get'); jest.spyOn(axios, 'get');
mock.onGet(expectedUrl).replyOnce(200, apiResponse); mock.onGet(expectedUrl).replyOnce(200, apiResponse);
...@@ -258,14 +271,13 @@ describe('Api', () => { ...@@ -258,14 +271,13 @@ describe('Api', () => {
describe('deleteProjectPackage', () => { describe('deleteProjectPackage', () => {
it('delete a package', () => { it('delete a package', () => {
const expectedUrl = `foo`; const expectedUrl = `foo`;
const apiResponse = true;
jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl); jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl);
jest.spyOn(axios, 'delete'); jest.spyOn(axios, 'delete');
mock.onDelete(expectedUrl).replyOnce(200, apiResponse); mock.onDelete(expectedUrl).replyOnce(200, true);
return Api.deleteProjectPackage(projectId, packageId).then(({ data }) => { return Api.deleteProjectPackage(projectId, packageId).then(({ data }) => {
expect(data).toEqual(apiResponse); expect(data).toEqual(true);
expect(axios.delete).toHaveBeenCalledWith(expectedUrl); expect(axios.delete).toHaveBeenCalledWith(expectedUrl);
}); });
}); });
......
...@@ -50,7 +50,7 @@ exports[`packages_list renders 1`] = ` ...@@ -50,7 +50,7 @@ exports[`packages_list renders 1`] = `
> >
<th <th
aria-colindex="1" aria-colindex="1"
class="" class="text-left"
role="columnheader" role="columnheader"
scope="col" scope="col"
> >
...@@ -58,7 +58,7 @@ exports[`packages_list renders 1`] = ` ...@@ -58,7 +58,7 @@ exports[`packages_list renders 1`] = `
</th> </th>
<th <th
aria-colindex="2" aria-colindex="2"
class="" class="text-center"
role="columnheader" role="columnheader"
scope="col" scope="col"
> >
...@@ -66,7 +66,7 @@ exports[`packages_list renders 1`] = ` ...@@ -66,7 +66,7 @@ exports[`packages_list renders 1`] = `
</th> </th>
<th <th
aria-colindex="3" aria-colindex="3"
class="" class="text-center"
role="columnheader" role="columnheader"
scope="col" scope="col"
> >
...@@ -74,7 +74,7 @@ exports[`packages_list renders 1`] = ` ...@@ -74,7 +74,7 @@ exports[`packages_list renders 1`] = `
</th> </th>
<th <th
aria-colindex="4" aria-colindex="4"
class="" class="text-center"
role="columnheader" role="columnheader"
scope="col" scope="col"
> >
...@@ -103,7 +103,7 @@ exports[`packages_list renders 1`] = ` ...@@ -103,7 +103,7 @@ exports[`packages_list renders 1`] = `
> >
<td <td
aria-colindex="1" aria-colindex="1"
class="w-25" class="text-left"
role="cell" role="cell"
> >
<div <div
...@@ -112,7 +112,7 @@ exports[`packages_list renders 1`] = ` ...@@ -112,7 +112,7 @@ exports[`packages_list renders 1`] = `
<a <a
class="flex-truncate-child" class="flex-truncate-child"
data-qa-selector="package_link" data-qa-selector="package_link"
href="/asd/lol" href="#"
> >
Test package Test package
...@@ -122,7 +122,7 @@ exports[`packages_list renders 1`] = ` ...@@ -122,7 +122,7 @@ exports[`packages_list renders 1`] = `
</td> </td>
<td <td
aria-colindex="2" aria-colindex="2"
class="" class="text-center"
role="cell" role="cell"
> >
...@@ -131,7 +131,7 @@ exports[`packages_list renders 1`] = ` ...@@ -131,7 +131,7 @@ exports[`packages_list renders 1`] = `
</td> </td>
<td <td
aria-colindex="3" aria-colindex="3"
class="" class="text-center"
role="cell" role="cell"
> >
...@@ -140,7 +140,7 @@ exports[`packages_list renders 1`] = ` ...@@ -140,7 +140,7 @@ exports[`packages_list renders 1`] = `
</td> </td>
<td <td
aria-colindex="4" aria-colindex="4"
class="" class="text-center"
role="cell" role="cell"
> >
<timeagotooltip-stub <timeagotooltip-stub
...@@ -172,7 +172,7 @@ exports[`packages_list renders 1`] = ` ...@@ -172,7 +172,7 @@ exports[`packages_list renders 1`] = `
> >
<td <td
aria-colindex="1" aria-colindex="1"
class="w-25" class="text-left"
role="cell" role="cell"
> >
<div <div
...@@ -181,7 +181,7 @@ exports[`packages_list renders 1`] = ` ...@@ -181,7 +181,7 @@ exports[`packages_list renders 1`] = `
<a <a
class="flex-truncate-child" class="flex-truncate-child"
data-qa-selector="package_link" data-qa-selector="package_link"
href="/asd/lol" href="#"
> >
@Test/package @Test/package
...@@ -191,7 +191,7 @@ exports[`packages_list renders 1`] = ` ...@@ -191,7 +191,7 @@ exports[`packages_list renders 1`] = `
</td> </td>
<td <td
aria-colindex="2" aria-colindex="2"
class="" class="text-center"
role="cell" role="cell"
> >
...@@ -200,7 +200,7 @@ exports[`packages_list renders 1`] = ` ...@@ -200,7 +200,7 @@ exports[`packages_list renders 1`] = `
</td> </td>
<td <td
aria-colindex="3" aria-colindex="3"
class="" class="text-center"
role="cell" role="cell"
> >
...@@ -209,7 +209,7 @@ exports[`packages_list renders 1`] = ` ...@@ -209,7 +209,7 @@ exports[`packages_list renders 1`] = `
</td> </td>
<td <td
aria-colindex="4" aria-colindex="4"
class="" class="text-center"
role="cell" role="cell"
> >
<timeagotooltip-stub <timeagotooltip-stub
...@@ -253,9 +253,9 @@ exports[`packages_list renders 1`] = ` ...@@ -253,9 +253,9 @@ exports[`packages_list renders 1`] = `
labelprevpage="Go to previous page" labelprevpage="Go to previous page"
limits="[object Object]" limits="[object Object]"
nexttext="Next ›" nexttext="Next ›"
perpage="20" perpage="1"
prevtext="‹ Prev" prevtext="‹ Prev"
totalitems="100" totalitems="1"
value="1" value="1"
/> />
......
...@@ -4,21 +4,38 @@ import PackageListApp from 'ee/packages/list/components/packages_list_app.vue'; ...@@ -4,21 +4,38 @@ import PackageListApp from 'ee/packages/list/components/packages_list_app.vue';
describe('packages_list_app', () => { describe('packages_list_app', () => {
let wrapper; let wrapper;
const emptyListHelpUrl = 'helpUrl'; const emptyListHelpUrl = 'helpUrl';
const findGlEmptyState = (w = wrapper) => w.find({ name: 'gl-empty-state-stub' }); const findGlEmptyState = (w = wrapper) => w.find({ name: 'gl-empty-state-stub' });
const findListComponent = (w = wrapper) => w.find({ name: 'package-list' });
const findLoadingComponent = (w = wrapper) => w.find({ name: 'gl-loading-icon' });
beforeEach(() => { const componentConfig = {
wrapper = shallowMount(PackageListApp, {
propsData: {
projectId: '1',
emptyListIllustration: 'helpSvg',
emptyListHelpUrl,
},
stubs: { stubs: {
'package-list': '<div><slot name="empty-state"></slot></div>', 'package-list': {
name: 'package-list',
template: '<div><slot name="empty-state"></slot></div>',
},
GlEmptyState: { ...GlEmptyState, name: 'gl-empty-state-stub' }, GlEmptyState: { ...GlEmptyState, name: 'gl-empty-state-stub' },
'gl-loading-icon': { name: 'gl-loading-icon', template: '<div>loading</div>' },
}, },
}); computed: {
isLoading: () => false,
emptyListIllustration: () => 'helpSvg',
emptyListHelpUrl: () => emptyListHelpUrl,
resourceId: () => 'project_id',
},
methods: {
requestPackagesList: jest.fn(),
requestDeletePackage: jest.fn(),
setProjectId: jest.fn(),
setGroupId: jest.fn(),
setUserCanDelete: jest.fn(),
},
};
beforeEach(() => {
wrapper = shallowMount(PackageListApp, componentConfig);
}); });
afterEach(() => { afterEach(() => {
...@@ -29,6 +46,21 @@ describe('packages_list_app', () => { ...@@ -29,6 +46,21 @@ describe('packages_list_app', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
describe('when isLoading is true', () => {
beforeEach(() => {
wrapper = shallowMount(PackageListApp, {
...componentConfig,
computed: {
isLoading: () => true,
},
});
});
it('shows the loading component', () => {
const loader = findLoadingComponent();
expect(loader.exists()).toBe(true);
});
});
it('generate the correct empty list link', () => { it('generate the correct empty list link', () => {
const emptyState = findGlEmptyState(); const emptyState = findGlEmptyState();
const link = emptyState.find('a'); const link = emptyState.find('a');
...@@ -37,4 +69,19 @@ describe('packages_list_app', () => { ...@@ -37,4 +69,19 @@ describe('packages_list_app', () => {
`"<a href=\\"${emptyListHelpUrl}\\" target=\\"_blank\\">publish and share your packages</a>"`, `"<a href=\\"${emptyListHelpUrl}\\" target=\\"_blank\\">publish and share your packages</a>"`,
); );
}); });
it('call requestPackagesList on page:changed', () => {
const list = findListComponent();
list.vm.$emit('page:changed', 1);
expect(componentConfig.methods.requestPackagesList).toHaveBeenCalledWith({ page: 1 });
});
it('call requestDeletePackage on package:delete', () => {
const list = findListComponent();
list.vm.$emit('package:delete', 1);
expect(componentConfig.methods.requestDeletePackage).toHaveBeenCalledWith({
projectId: 'project_id',
packageId: 1,
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import _ from 'underscore';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlTable } from '@gitlab/ui'; import { GlTable } from '@gitlab/ui';
import PackagesList from 'ee/packages/list/components/packages_list.vue'; import PackagesList from 'ee/packages/list/components/packages_list.vue';
...@@ -13,17 +14,20 @@ describe('packages_list', () => { ...@@ -13,17 +14,20 @@ describe('packages_list', () => {
const findPackageListPagination = (w = wrapper) => w.find({ ref: 'packageListPagination' }); const findPackageListPagination = (w = wrapper) => w.find({ ref: 'packageListPagination' });
const findPackageListDeleteModal = (w = wrapper) => w.find({ ref: 'packageListDeleteModal' }); const findPackageListDeleteModal = (w = wrapper) => w.find({ ref: 'packageListDeleteModal' });
const findSortingItems = (w = wrapper) => w.findAll({ name: 'sorting-item-stub' }); const findSortingItems = (w = wrapper) => w.findAll({ name: 'sorting-item-stub' });
const findFirstProjectColumn = (w = wrapper) => w.find({ ref: 'col-project' });
const defaultShallowMountOptions = { const defaultShallowMountOptions = {
propsData: {
canDestroyPackage: true,
},
stubs: { stubs: {
GlTable, GlTable,
GlSortingItem: { name: 'sorting-item-stub', template: '<div><slot></slot></div>' }, GlSortingItem: { name: 'sorting-item-stub', template: '<div><slot></slot></div>' },
}, },
computed: { computed: {
list: () => [...packageList], list: () => [...packageList],
perPage: () => 1,
totalItems: () => 1,
page: () => 1,
canDestroyPackage: () => true,
isGroupPage: () => false,
}, },
}; };
...@@ -43,6 +47,24 @@ describe('packages_list', () => { ...@@ -43,6 +47,24 @@ describe('packages_list', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
describe('when is isGroupPage', () => {
beforeEach(() => {
wrapper = shallowMount(PackagesList, {
...defaultShallowMountOptions,
computed: {
...defaultShallowMountOptions.computed,
canDestroyPackage: () => false,
isGroupPage: () => true,
},
});
});
it('has project field', () => {
const projectColumn = findFirstProjectColumn();
expect(projectColumn.exists()).toBe(true);
});
});
it('contains a sorting component', () => { it('contains a sorting component', () => {
const sorting = findPackageListSorting(); const sorting = findPackageListSorting();
expect(sorting.exists()).toBe(true); expect(sorting.exists()).toBe(true);
...@@ -63,8 +85,14 @@ describe('packages_list', () => { ...@@ -63,8 +85,14 @@ describe('packages_list', () => {
}); });
describe('when user can not destroy the package', () => { describe('when user can not destroy the package', () => {
beforeEach(() => {
wrapper = shallowMount(PackagesList, {
...defaultShallowMountOptions,
computed: { ...defaultShallowMountOptions.computed, canDestroyPackage: () => false },
});
});
it('does not show the action column', () => { it('does not show the action column', () => {
wrapper.setProps({ canDestroyPackage: false });
const action = findFirstActionColumn(); const action = findFirstActionColumn();
expect(action.exists()).toBe(false); expect(action.exists()).toBe(false);
}); });
...@@ -79,19 +107,19 @@ describe('packages_list', () => { ...@@ -79,19 +107,19 @@ describe('packages_list', () => {
it('shows the correct deletePackageDescription', () => { it('shows the correct deletePackageDescription', () => {
expect(wrapper.vm.deletePackageDescription).toEqual(''); expect(wrapper.vm.deletePackageDescription).toEqual('');
wrapper.setData({ itemToBeDeleted: { name: 'foo' } }); wrapper.setData({ itemToBeDeleted: { name: 'foo', version: '1.0.10-beta' } });
expect(wrapper.vm.deletePackageDescription).toEqual( expect(wrapper.vm.deletePackageDescription).toMatchInlineSnapshot(
'You are about to delete <b>foo</b>, this operation is irreversible, are you sure?', `"You are about to delete <b>foo:1.0.10-beta</b>, this operation is irreversible, are you sure?"`,
); );
}); });
it('delete button set itemToBeDeleted and open the modal', () => { it('delete button set itemToBeDeleted and open the modal', () => {
wrapper.vm.$refs.packageListDeleteModal.show = jest.fn(); wrapper.vm.$refs.packageListDeleteModal.show = jest.fn();
const [{ name, id }] = packageList.slice(-1); const item = _.last(packageList);
const action = findFirstActionColumn(); const action = findFirstActionColumn();
action.vm.$emit('click'); action.vm.$emit('click');
return Vue.nextTick().then(() => { return Vue.nextTick().then(() => {
expect(wrapper.vm.itemToBeDeleted).toEqual({ id, name }); expect(wrapper.vm.itemToBeDeleted).toEqual(item);
expect(wrapper.vm.$refs.packageListDeleteModal.show).toHaveBeenCalled(); expect(wrapper.vm.$refs.packageListDeleteModal.show).toHaveBeenCalled();
}); });
}); });
...@@ -101,6 +129,11 @@ describe('packages_list', () => { ...@@ -101,6 +129,11 @@ describe('packages_list', () => {
wrapper.vm.deleteItemConfirmation(); wrapper.vm.deleteItemConfirmation();
expect(wrapper.vm.itemToBeDeleted).toEqual(null); expect(wrapper.vm.itemToBeDeleted).toEqual(null);
}); });
it('deleteItemConfirmation emit package:delete', () => {
wrapper.setData({ itemToBeDeleted: { id: 2 } });
wrapper.vm.deleteItemConfirmation();
expect(wrapper.emitted('package:delete')).toEqual([[2]]);
});
it('deleteItemCanceled resets itemToBeDeleted', () => { it('deleteItemCanceled resets itemToBeDeleted', () => {
wrapper.setData({ itemToBeDeleted: 1 }); wrapper.setData({ itemToBeDeleted: 1 });
...@@ -135,5 +168,9 @@ describe('packages_list', () => { ...@@ -135,5 +168,9 @@ describe('packages_list', () => {
const sortingItems = findSortingItems(); const sortingItems = findSortingItems();
expect(sortingItems.length).toEqual(wrapper.vm.sortableFields.length); expect(sortingItems.length).toEqual(wrapper.vm.sortableFields.length);
}); });
it('emits page:changed events when the page changes', () => {
wrapper.vm.currentPage = 2;
expect(wrapper.emitted('page:changed')).toEqual([[2]]);
});
}); });
}); });
...@@ -2,40 +2,57 @@ import * as actions from 'ee/packages/list/stores/actions'; ...@@ -2,40 +2,57 @@ import * as actions from 'ee/packages/list/stores/actions';
import * as types from 'ee/packages/list/stores/mutation_types'; import * as types from 'ee/packages/list/stores/mutation_types';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash'; import createFlash from '~/flash';
import Api from '~/api'; import Api from 'ee/api';
jest.mock('~/flash.js'); jest.mock('~/flash.js');
jest.mock('~/api.js'); jest.mock('ee/api.js');
describe('Actions Package list store', () => { describe('Actions Package list store', () => {
let state; const headers = 'bar';
describe('requestPackagesList', () => {
beforeEach(() => { beforeEach(() => {
state = { Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo', headers });
pagination: { Api.groupPackages = jest.fn().mockResolvedValue({ data: 'baz', headers });
page: 1,
perPage: 10,
},
};
}); });
describe('requestPackagesList', () => { it('should fetch the project packages list when isGroupPage is false', done => {
beforeEach(() => { testAction(
Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo' }); actions.requestPackagesList,
undefined,
{ config: { isGroupPage: false, resourceId: 1 } },
[],
[
{ type: 'setLoading', payload: true },
{ type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } },
{ type: 'setLoading', payload: false },
],
() => {
expect(Api.projectPackages).toHaveBeenCalledWith(1, {
params: { page: 1, per_page: 20 },
});
done();
},
);
}); });
it('should dispatch the correct actions', done => { it('should fetch the group packages list when isGroupPage is true', done => {
testAction( testAction(
actions.requestPackagesList, actions.requestPackagesList,
null, undefined,
state, { config: { isGroupPage: true, resourceId: 2 } },
[], [],
[ [
{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: true },
{ type: 'receivePackagesListSuccess', payload: 'foo' }, { type: 'receivePackagesListSuccess', payload: { data: 'baz', headers } },
{ type: 'setLoading', payload: false }, { type: 'setLoading', payload: false },
], ],
done, () => {
expect(Api.groupPackages).toHaveBeenCalledWith(2, {
params: { page: 1, per_page: 20 },
});
done();
},
); );
}); });
...@@ -43,8 +60,8 @@ describe('Actions Package list store', () => { ...@@ -43,8 +60,8 @@ describe('Actions Package list store', () => {
Api.projectPackages = jest.fn().mockRejectedValue(); Api.projectPackages = jest.fn().mockRejectedValue();
testAction( testAction(
actions.requestPackagesList, actions.requestPackagesList,
null, undefined,
state, { config: { isGroupPage: false, resourceId: 2 } },
[], [],
[{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }], [{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }],
() => { () => {
...@@ -57,37 +74,29 @@ describe('Actions Package list store', () => { ...@@ -57,37 +74,29 @@ describe('Actions Package list store', () => {
describe('receivePackagesListSuccess', () => { describe('receivePackagesListSuccess', () => {
it('should set received packages', done => { it('should set received packages', done => {
const data = 'foo';
testAction( testAction(
actions.receivePackagesListSuccess, actions.receivePackagesListSuccess,
'foo', { data, headers },
state, null,
[{ type: types.SET_PACKAGE_LIST_SUCCESS, payload: 'foo' }], [
{ type: types.SET_PACKAGE_LIST_SUCCESS, payload: data },
{ type: types.SET_PAGINATION, payload: headers },
],
[], [],
done, done,
); );
}); });
}); });
describe('setProjectId', () => { describe('setInitialState', () => {
it('should commit setProjectId', done => { it('should commit setInitialState', done => {
testAction( testAction(
actions.setProjectId, actions.setInitialState,
'1', '1',
state, null,
[{ type: types.SET_PROJECT_ID, payload: '1' }], [{ type: types.SET_INITIAL_STATE, payload: '1' }],
[],
done,
);
});
});
describe('setUserCanDelete', () => {
it('should commit setUserCanDelete', done => {
testAction(
actions.setUserCanDelete,
true,
state,
[{ type: types.SET_USER_CAN_DELETE, payload: true }],
[], [],
done, done,
); );
...@@ -99,7 +108,7 @@ describe('Actions Package list store', () => { ...@@ -99,7 +108,7 @@ describe('Actions Package list store', () => {
testAction( testAction(
actions.setLoading, actions.setLoading,
true, true,
state, null,
[{ type: types.SET_MAIN_LOADING, payload: true }], [{ type: types.SET_MAIN_LOADING, payload: true }],
[], [],
done, done,
...@@ -122,7 +131,7 @@ describe('Actions Package list store', () => { ...@@ -122,7 +131,7 @@ describe('Actions Package list store', () => {
[], [],
[ [
{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: true },
{ type: 'fetchPackages' }, { type: 'requestPackagesList' },
{ type: 'setLoading', payload: false }, { type: 'setLoading', payload: false },
], ],
done, done,
......
...@@ -10,29 +10,35 @@ describe('Mutations Registry Store', () => { ...@@ -10,29 +10,35 @@ describe('Mutations Registry Store', () => {
mockState = createState(); mockState = createState();
}); });
describe('SET_PROJECT_ID', () => { describe('SET_INITIAL_STATE', () => {
it('should set the project id', () => { it('should set the initial state', () => {
const expectedState = { ...mockState, projectId: 'foo' }; const config = {
mutations[types.SET_PROJECT_ID](mockState, 'foo'); resourceId: '1',
pageType: 'groups',
userCanDelete: '',
emptyListIllustration: 'foo',
emptyListHelpUrl: 'baz',
};
expect(mockState.projectId).toEqual(expectedState.projectId); const expectedState = {
}); ...mockState,
}); config: {
...config,
isGroupPage: true,
canDestroyPackage: true,
},
};
mutations[types.SET_INITIAL_STATE](mockState, config);
describe('SET_USER_CAN_DELETE', () => { expect(mockState.projectId).toEqual(expectedState.projectId);
it('should set the userCanDelete', () => {
const expectedState = { ...mockState, userCanDelete: true };
mutations[types.SET_USER_CAN_DELETE](mockState, true);
expect(mockState.userCanDelete).toEqual(expectedState.userCanDelete);
}); });
}); });
describe('SET_PACKAGE_LIST', () => { describe('SET_PACKAGE_LIST_SUCCESS', () => {
it('should set a packages list', () => { it('should set a packages list', () => {
const payload = [npmPackage, mavenPackage]; const payload = [npmPackage, mavenPackage];
const expectedState = { ...mockState, packages: payload }; const expectedState = { ...mockState, packages: payload };
mutations[types.SET_PACKAGE_LIST](mockState, payload); mutations[types.SET_PACKAGE_LIST_SUCCESS](mockState, payload);
expect(mockState.packages).toEqual(expectedState.packages); expect(mockState.packages).toEqual(expectedState.packages);
}); });
...@@ -53,7 +59,7 @@ describe('Mutations Registry Store', () => { ...@@ -53,7 +59,7 @@ describe('Mutations Registry Store', () => {
commonUtils.parseIntPagination = jest.fn().mockReturnValue(mockPagination); commonUtils.parseIntPagination = jest.fn().mockReturnValue(mockPagination);
}); });
it('should set a parsed pagination', () => { it('should set a parsed pagination', () => {
mutations[types.SET_PAGINATION](mockState, { headers: 'foo' }); mutations[types.SET_PAGINATION](mockState, 'foo');
expect(commonUtils.normalizeHeaders).toHaveBeenCalledWith('foo'); expect(commonUtils.normalizeHeaders).toHaveBeenCalledWith('foo');
expect(commonUtils.parseIntPagination).toHaveBeenCalledWith('baz'); expect(commonUtils.parseIntPagination).toHaveBeenCalledWith('baz');
expect(mockState.pagination).toEqual(mockPagination); expect(mockState.pagination).toEqual(mockPagination);
......
...@@ -11947,6 +11947,9 @@ msgstr "" ...@@ -11947,6 +11947,9 @@ msgstr ""
msgid "Owner" msgid "Owner"
msgstr "" msgstr ""
msgid "Package deleted successfully"
msgstr ""
msgid "Package information" msgid "Package information"
msgstr "" msgstr ""
...@@ -11965,10 +11968,10 @@ msgstr "" ...@@ -11965,10 +11968,10 @@ msgstr ""
msgid "PackageRegistry|Copy yarn setup command" msgid "PackageRegistry|Copy yarn setup command"
msgstr "" msgstr ""
msgid "PackageRegistry|Delete Package" msgid "PackageRegistry|Delete Package Version"
msgstr "" msgstr ""
msgid "PackageRegistry|Delete Package Version" msgid "PackageRegistry|Delete package"
msgstr "" msgstr ""
msgid "PackageRegistry|Installation" msgid "PackageRegistry|Installation"
......
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