Commit efff24ac authored by Natalia Tepluhina's avatar Natalia Tepluhina

Scaffolded select component

- added a new shared component
- refactored assignees widget
Fixed searching

Fixed apply button

Refactored moving user to start

Managed to show project members

Fixed displaying project members

Fixed assigning self

Regenerated translations file

Started fixing Jest tests

Fixed assignees widget spec

Scaffolded user select test

Added a changelog entry

Added test for loading and current user

Added spec for unassign link

Added tests for selecting users

Added error tests

Changed imports order

Renamed multipleAssignees prop

Added watchLoading property

Added shouldShowParticipants computed

Fix test cases names

Fixed styling for loader

Fixed sidebar assignees test

Fixed assignees widget spec

Fixed assignees realtime spec

Abstracted participants query

Fixed MR-related queries

Added missing fragments

Removed a query copy
parent ad2cb86b
......@@ -3,6 +3,9 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
i18n: {
unassigned: __('Unassigned'),
},
components: { GlButton, GlLoadingIcon },
inject: {
canUpdate: {},
......
......@@ -12,22 +12,33 @@ import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mu
import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql';
import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
export const ASSIGNEES_DEBOUNCE_DELAY = 250;
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
query: getIssueAssignees,
subscription: issuableAssigneesSubscription,
mutation: updateAssigneesMutation,
mutation: updateIssueAssigneesMutation,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestAssignees,
mutation: updateMergeRequestAssigneesMutation,
},
};
export const participantsQueries = {
[IssuableType.Issue]: {
query: issueParticipantsQuery,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestParticipants,
mutation: updateMergeRequestParticipantsMutation,
},
};
......
......@@ -108,7 +108,7 @@ function mountAssigneesComponent() {
? IssuableType.Issue
: IssuableType.MergeRequest,
issuableId: id,
multipleAssignees: !el.dataset.maxAssignees,
allowMultipleAssignees: !el.dataset.maxAssignees,
},
scopedSlots: {
collapsed: ({ users, onClick }) =>
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
assignees {
nodes {
...User
...UserAvailability
}
}
}
}
}
......@@ -13,12 +13,6 @@ query issueParticipants($fullPath: ID!, $iid: String!) {
...UserAvailability
}
}
assignees {
nodes {
...User
...UserAvailability
}
}
}
}
}
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query getMrAssignees($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
issuable: mergeRequest(iid: $iid) {
id
assignees {
nodes {
...User
...UserAvailability
}
}
}
}
}
......@@ -11,12 +11,6 @@ query getMrParticipants($fullPath: ID!, $iid: String!) {
...UserAvailability
}
}
assignees {
nodes {
...User
...UserAvailability
}
}
}
}
}
......@@ -13,12 +13,6 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
...UserAvailability
}
}
participants {
nodes {
...User
...UserAvailability
}
}
}
}
}
<script>
import {
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants';
export default {
i18n: {
unassigned: __('Unassigned'),
},
components: {
GlDropdownForm,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
SidebarParticipant,
GlLoadingIcon,
},
props: {
headerText: {
type: String,
required: true,
},
text: {
type: String,
required: true,
},
fullPath: {
type: String,
required: true,
},
iid: {
type: String,
required: true,
},
value: {
type: Array,
required: true,
},
allowMultipleAssignees: {
type: Boolean,
required: false,
default: false,
},
currentUser: {
type: Object,
required: true,
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
},
data() {
return {
search: '',
participants: [],
searchUsers: [],
isSearching: false,
};
},
apollo: {
participants: {
query() {
return participantsQueries[this.issuableType].query;
},
variables() {
return {
iid: this.iid,
fullPath: this.fullPath,
};
},
update(data) {
return data.workspace?.issuable?.participants.nodes;
},
error() {
this.$emit('error');
},
},
searchUsers: {
query: searchUsers,
variables() {
return {
fullPath: this.fullPath,
search: this.search,
first: 20,
};
},
update(data) {
return data.workspace?.users?.nodes.map(({ user }) => user) || [];
},
debounce: ASSIGNEES_DEBOUNCE_DELAY,
error() {
this.$emit('error');
this.isSearching = false;
},
result() {
this.isSearching = false;
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
},
users() {
if (!this.participants) {
return [];
}
const mergedSearchResults = this.participants.reduce((acc, current) => {
if (
!acc.some((user) => current.username === user.username) &&
(current.name.includes(this.search) || current.username.includes(this.search))
) {
acc.push(current);
}
return acc;
}, this.searchUsers);
return this.moveCurrentUserToStart(mergedSearchResults);
},
isSearchEmpty() {
return this.search === '';
},
shouldShowParticipants() {
return this.isSearchEmpty || this.isSearching;
},
isCurrentUserInList() {
const isCurrentUser = (user) => user.username === this.currentUser.username;
return this.users.some(isCurrentUser);
},
noUsersFound() {
return !this.isSearchEmpty && this.users.length === 0;
},
showCurrentUser() {
return this.currentUser.username && !this.isCurrentUserInList && this.isSearchEmpty;
},
selectedFiltered() {
if (this.shouldShowParticipants) {
return this.moveCurrentUserToStart(this.value);
}
const foundUsernames = this.users.map(({ username }) => username);
const filtered = this.value.filter(({ username }) => foundUsernames.includes(username));
return this.moveCurrentUserToStart(filtered);
},
selectedUserNames() {
return this.value.map(({ username }) => username);
},
unselectedFiltered() {
return this.users?.filter(({ username }) => !this.selectedUserNames.includes(username)) || [];
},
selectedIsEmpty() {
return this.selectedFiltered.length === 0;
},
},
watch: {
// We need to add this watcher to track the moment when user is alredy typing
// but query is still not started due to debounce
search(newVal) {
if (newVal) {
this.isSearching = true;
}
},
},
methods: {
selectAssignee(user) {
let selected = [...this.value];
if (!this.allowMultipleAssignees) {
selected = [user];
} else {
selected.push(user);
}
this.$emit('input', selected);
},
unselect(name) {
const selected = this.value.filter((user) => user.username !== name);
this.$emit('input', selected);
},
focusSearch() {
this.$refs.search.focusInput();
},
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
moveCurrentUserToStart(users) {
if (!users) {
return [];
}
const usersCopy = [...users];
const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
if (currentUser) {
const index = usersCopy.indexOf(currentUser);
usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
}
return usersCopy;
},
},
};
</script>
<template>
<gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
<template #header>
<p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
<gl-dropdown-divider />
<gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" />
</template>
<gl-dropdown-form class="gl-relative gl-min-h-7">
<gl-loading-icon
v-if="isLoading"
data-testid="loading-participants"
size="md"
class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
/>
<template v-else>
<template v-if="shouldShowParticipants">
<gl-dropdown-item
v-if="isSearchEmpty"
:is-checked="selectedIsEmpty"
:is-check-centered="true"
data-testid="unassign"
@click="$emit('input', [])"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned
}}</span></gl-dropdown-item
>
</template>
<gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
is-checked
is-check-centered
data-testid="selected-participant"
@click.stop="unselect(item.username)"
>
<sidebar-participant :user="item" />
</gl-dropdown-item>
<template v-if="showCurrentUser">
<gl-dropdown-divider />
<gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)">
<sidebar-participant :user="currentUser" class="gl-pl-6!" />
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
data-testid="unselected-participant"
@click="selectAssignee(unselectedUser)"
>
<sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
{{ __('No matching results') }}
</gl-dropdown-item>
</template>
</gl-dropdown-form>
<template #footer>
<slot name="footer"></slot>
</template>
</gl-dropdown>
</template>
---
title: Resolve Abstract participants dropdown to a shared component
merge_request: 59358
author:
type: changed
......@@ -3673,9 +3673,6 @@ msgstr ""
msgid "An error occurred while saving changes: %{error}"
msgstr ""
msgid "An error occurred while searching users."
msgstr ""
msgid "An error occurred while subscribing to notifications."
msgstr ""
......
......@@ -4,7 +4,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data';
const localVue = createLocalVue();
......@@ -24,7 +24,7 @@ describe('Assignees Realtime', () => {
subscriptionHandler = subscriptionInitialHandler,
} = {}) => {
fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler],
[getIssueAssigneesQuery, issuableQueryHandler],
[issuableAssigneesSubscription, subscriptionHandler],
]);
wrapper = shallowMount(AssigneesRealtime, {
......
......@@ -283,38 +283,6 @@ export const issuableQueryResponse = {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
iid: '1',
participants: {
nodes: [
{
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
status: null,
},
{
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
status: {
availability: 'BUSY',
},
},
{
id: 'gid://gitlab/User/3',
avatarUrl: '/avatar',
name: 'John Doe',
username: 'johndoe',
webUrl: '/john',
status: null,
},
],
},
assignees: {
nodes: [
{
......@@ -386,10 +354,107 @@ export const updateIssueAssigneesMutationResponse = {
],
__typename: 'UserConnection',
},
__typename: 'Issue',
},
},
},
};
export const subscriptionNullResponse = {
data: {
issuableAssigneesUpdated: null,
},
};
export const searchResponse = {
data: {
workspace: {
__typename: 'Project',
users: {
nodes: [
{
user: {
id: '1',
avatarUrl: '/avatar',
name: 'root',
username: 'root',
webUrl: 'root',
status: null,
},
},
{
user: {
id: '2',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
status: null,
},
},
],
},
},
},
};
export const projectMembersResponse = {
data: {
workspace: {
__typename: 'Project',
users: {
nodes: [
{
user: {
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
status: null,
},
},
{
user: {
id: '2',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
status: null,
},
},
{
user: {
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
status: {
availability: 'BUSY',
},
},
},
],
},
},
},
};
export const participantsQueryResponse = {
data: {
workspace: {
__typename: 'Project',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
iid: '1',
participants: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
......@@ -399,28 +464,29 @@ export const updateIssueAssigneesMutationResponse = {
status: null,
},
{
__typename: 'User',
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
status: {
availability: 'BUSY',
},
},
{
id: 'gid://gitlab/User/3',
avatarUrl: '/avatar',
name: 'John Doe',
username: 'rollie',
webUrl: '/john',
status: null,
},
],
__typename: 'UserConnection',
},
__typename: 'Issue',
},
},
},
};
export const subscriptionNullResponse = {
data: {
issuableAssigneesUpdated: null,
},
};
export default mockData;
import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import {
searchResponse,
projectMembersResponse,
participantsQueryResponse,
} from '../../sidebar/mock_data';
const assignee = {
id: 'gid://gitlab/User/4',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Developer',
username: 'dev',
webUrl: '/dev',
status: null,
};
const mockError = jest.fn().mockRejectedValue('Error!');
const waitForSearch = async () => {
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
await waitForPromises();
};
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('User select dropdown', () => {
let wrapper;
let fakeApollo;
const findSearchField = () => wrapper.findComponent(GlSearchBoxByType);
const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]');
const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]');
const findUnselectedParticipants = () =>
wrapper.findAll('[data-testid="unselected-participant"]');
const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]');
const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
const createComponent = ({
props = {},
searchQueryHandler = jest.fn().mockResolvedValue(projectMembersResponse),
participantsQueryHandler = jest.fn().mockResolvedValue(participantsQueryResponse),
} = {}) => {
fakeApollo = createMockApollo([
[searchUsersQuery, searchQueryHandler],
[getIssueParticipantsQuery, participantsQueryHandler],
]);
wrapper = shallowMount(UserSelect, {
localVue,
apolloProvider: fakeApollo,
propsData: {
headerText: 'test',
text: 'test-text',
fullPath: '/project',
iid: '1',
value: [],
currentUser: {
username: 'random',
name: 'Mr. Random',
},
allowMultipleAssignees: false,
...props,
},
stubs: {
GlDropdown,
},
});
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
it('renders a loading spinner if participants are loading', () => {
createComponent();
expect(findParticipantsLoading().exists()).toBe(true);
});
it('emits an `error` event if participants query was rejected', async () => {
createComponent({ participantsQueryHandler: mockError });
await waitForPromises();
expect(wrapper.emitted('error')).toBeTruthy();
});
it('emits an `error` event if search query was rejected', async () => {
createComponent({ searchQueryHandler: mockError });
await waitForSearch();
expect(wrapper.emitted('error')).toBeTruthy();
});
it('renders current user if they are not in participants or assignees', async () => {
createComponent();
await waitForPromises();
expect(findCurrentUser().exists()).toBe(true);
});
it('displays correct amount of selected users', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
expect(findSelectedParticipants()).toHaveLength(1);
});
describe('when search is empty', () => {
it('renders a merged list of participants and project members', async () => {
createComponent();
await waitForPromises();
expect(findUnselectedParticipants()).toHaveLength(3);
});
it('renders `Unassigned` link with the checkmark when there are no selected users', async () => {
createComponent();
await waitForPromises();
expect(findUnassignLink().props('isChecked')).toBe(true);
});
it('renders `Unassigned` link without the checkmark when there are selected users', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
expect(findUnassignLink().props('isChecked')).toBe(false);
});
it('emits an input event with empty array after clicking on `Unassigned`', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
findUnassignLink().vm.$emit('click');
expect(wrapper.emitted('input')).toEqual([[[]]]);
});
it('emits an empty array after unselecting the only selected assignee', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
expect(wrapper.emitted('input')).toEqual([[[]]]);
});
it('allows only one user to be selected if `allowMultipleAssignees` is false', async () => {
createComponent({
props: {
value: [assignee],
},
});
await waitForPromises();
findUnselectedParticipants().at(0).vm.$emit('click');
expect(wrapper.emitted('input')).toEqual([
[
[
{
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
id: 'gid://gitlab/User/1',
name: 'Administrator',
status: null,
username: 'root',
webUrl: '/root',
},
],
],
]);
});
it('adds user to selected if `allowMultipleAssignees` is true', async () => {
createComponent({
props: {
value: [assignee],
allowMultipleAssignees: true,
},
});
await waitForPromises();
findUnselectedParticipants().at(0).vm.$emit('click');
expect(wrapper.emitted('input')[0][0]).toHaveLength(2);
});
});
describe('when searching', () => {
it('does not show loading spinner when debounce timer is still running', async () => {
createComponent();
await waitForPromises();
findSearchField().vm.$emit('input', 'roo');
expect(findParticipantsLoading().exists()).toBe(false);
});
it('shows loading spinner when searching for users', async () => {
createComponent();
await waitForPromises();
findSearchField().vm.$emit('input', 'roo');
jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
await nextTick();
expect(findParticipantsLoading().exists()).toBe(true);
});
it('renders a list of found users and external participants matching search term', async () => {
createComponent({ searchQueryHandler: jest.fn().mockResolvedValue(searchResponse) });
await waitForPromises();
findSearchField().vm.$emit('input', 'ro');
await waitForSearch();
expect(findUnselectedParticipants()).toHaveLength(3);
});
it('renders a list of found users only if no external participants match search term', async () => {
createComponent({ searchQueryHandler: jest.fn().mockResolvedValue(searchResponse) });
await waitForPromises();
findSearchField().vm.$emit('input', 'roo');
await waitForSearch();
expect(findUnselectedParticipants()).toHaveLength(2);
});
it('shows a message about no matches if search returned an empty list', async () => {
const responseCopy = cloneDeep(searchResponse);
responseCopy.data.workspace.users.nodes = [];
createComponent({
searchQueryHandler: jest.fn().mockResolvedValue(responseCopy),
});
await waitForPromises();
findSearchField().vm.$emit('input', 'tango');
await waitForSearch();
expect(findUnselectedParticipants()).toHaveLength(0);
expect(findEmptySearchResults().exists()).toBe(true);
});
});
});
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