Commit 26940f36 authored by Peter Hegman's avatar Peter Hegman Committed by Kushal Pandya

Add member avatar component

Adds member avatar related components to group members view
parent 9748207a
<script>
import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui';
import { AVATAR_SIZE } from '../constants';
export default {
name: 'GroupAvatar',
avatarSize: AVATAR_SIZE,
components: { GlAvatarLink, GlAvatarLabeled },
props: {
member: {
type: Object,
required: true,
},
},
computed: {
group() {
return this.member.sharedWithGroup;
},
},
};
</script>
<template>
<gl-avatar-link :href="group.webUrl">
<gl-avatar-labeled
:label="group.fullName"
:src="group.avatarUrl"
:alt="group.fullName"
:size="$options.avatarSize"
:entity-name="group.name"
:entity-id="group.id"
/>
</gl-avatar-link>
</template>
<script>
import { GlAvatarLabeled } from '@gitlab/ui';
import { AVATAR_SIZE } from '../constants';
export default {
name: 'InviteAvatar',
avatarSize: AVATAR_SIZE,
components: { GlAvatarLabeled },
props: {
member: {
type: Object,
required: true,
},
},
computed: {
invite() {
return this.member.invite;
},
},
};
</script>
<template>
<gl-avatar-labeled
:label="invite.email"
:src="invite.avatarUrl"
:alt="invite.email"
:size="$options.avatarSize"
:entity-name="invite.email"
:entity-id="member.id"
/>
</template>
<script>
import { GlAvatarLink, GlAvatarLabeled, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { __ } from '~/locale';
import { AVATAR_SIZE } from '../constants';
export default {
name: 'UserAvatar',
avatarSize: AVATAR_SIZE,
orphanedUserLabel: __('Orphaned member'),
components: { GlAvatarLink, GlAvatarLabeled },
directives: {
SafeHtml,
},
props: {
member: {
type: Object,
required: true,
},
},
computed: {
user() {
return this.member.user;
},
},
};
</script>
<template>
<gl-avatar-link
v-if="user"
class="js-user-link"
:href="user.webUrl"
:data-user-id="user.id"
:data-username="user.username"
>
<gl-avatar-labeled
:label="user.name"
:sub-label="`@${user.username}`"
:src="user.avatarUrl"
:alt="user.name"
:size="$options.avatarSize"
:entity-name="user.name"
:entity-id="user.id"
/>
</gl-avatar-link>
<gl-avatar-labeled
v-else
:label="$options.orphanedUserLabel"
:alt="$options.orphanedUserLabel"
:size="$options.avatarSize"
:entity-name="$options.orphanedUserLabel"
:entity-id="member.id"
/>
</template>
......@@ -53,3 +53,12 @@ export const FIELDS = [
tdClass: 'col-actions',
},
];
export const AVATAR_SIZE = 48;
export const MEMBER_TYPES = {
user: 'user',
group: 'group',
invite: 'invite',
accessRequest: 'accessRequest',
};
<script>
import { kebabCase } from 'lodash';
import UserAvatar from '../avatars/user_avatar.vue';
import InviteAvatar from '../avatars/invite_avatar.vue';
import GroupAvatar from '../avatars/group_avatar.vue';
export default {
name: 'MemberAvatar',
components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar },
props: {
memberType: {
type: String,
required: true,
},
member: {
type: Object,
required: true,
},
},
computed: {
avatarComponent() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${kebabCase(this.memberType)}-avatar`;
},
},
};
</script>
<template>
<component :is="avatarComponent" :member="member" />
</template>
......@@ -3,11 +3,15 @@ import { mapState } from 'vuex';
import { GlTable } from '@gitlab/ui';
import { FIELDS } from '../constants';
import initUserPopovers from '~/user_popovers';
import MemberAvatar from './member_avatar.vue';
import MembersTableCell from './members_table_cell.vue';
export default {
name: 'MembersTable',
components: {
GlTable,
MemberAvatar,
MembersTableCell,
},
computed: {
...mapState(['members', 'tableFields']),
......@@ -33,6 +37,12 @@ export default {
:empty-text="__('No members found')"
show-empty
>
<template #cell(account)="{ item: member }">
<members-table-cell #default="{ memberType }" :member="member">
<member-avatar :member-type="memberType" :member="member" />
</members-table-cell>
</template>
<template #cell(source)>
<!-- Temporarily empty -->
</template>
......
<script>
import { MEMBER_TYPES } from '../constants';
export default {
name: 'MembersTableCell',
props: {
member: {
type: Object,
required: true,
},
},
computed: {
isGroup() {
return Boolean(this.member.sharedWithGroup);
},
isInvite() {
return Boolean(this.member.invite);
},
isAccessRequest() {
return Boolean(this.member.requestedAt);
},
memberType() {
if (this.isGroup) {
return MEMBER_TYPES.group;
} else if (this.isInvite) {
return MEMBER_TYPES.invite;
} else if (this.isAccessRequest) {
return MEMBER_TYPES.accessRequest;
}
return MEMBER_TYPES.user;
},
},
render() {
return this.$scopedSlots.default({
memberType: this.memberType,
});
},
};
</script>
......@@ -17931,6 +17931,9 @@ msgstr ""
msgid "Origin"
msgstr ""
msgid "Orphaned member"
msgstr ""
msgid "Other Labels"
msgstr ""
......
import { mount, createWrapper } from '@vue/test-utils';
import { getByText as getByTextHelper } from '@testing-library/dom';
import { GlAvatarLink } from '@gitlab/ui';
import { group as member } from '../mock_data';
import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
describe('MemberList', () => {
let wrapper;
const group = member.sharedWithGroup;
const createComponent = (propsData = {}) => {
wrapper = mount(GroupAvatar, {
propsData: {
member,
...propsData,
},
});
};
const getByText = (text, options) =>
createWrapper(getByTextHelper(wrapper.element, text, options));
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders link to group', () => {
const link = wrapper.find(GlAvatarLink);
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(group.webUrl);
});
it("renders group's full name", () => {
expect(getByText(group.fullName).exists()).toBe(true);
});
it("renders group's avatar", () => {
expect(wrapper.find('img').attributes('src')).toBe(group.avatarUrl);
});
});
import { mount, createWrapper } from '@vue/test-utils';
import { getByText as getByTextHelper } from '@testing-library/dom';
import { invite as member } from '../mock_data';
import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
describe('MemberList', () => {
let wrapper;
const { invite } = member;
const createComponent = (propsData = {}) => {
wrapper = mount(InviteAvatar, {
propsData: {
member,
...propsData,
},
});
};
const getByText = (text, options) =>
createWrapper(getByTextHelper(wrapper.element, text, options));
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders email as name', () => {
expect(getByText(invite.email).exists()).toBe(true);
});
it('renders avatar', () => {
expect(wrapper.find('img').attributes('src')).toBe(invite.avatarUrl);
});
});
import { mount, createWrapper } from '@vue/test-utils';
import { getByText as getByTextHelper } from '@testing-library/dom';
import { GlAvatarLink } from '@gitlab/ui';
import { member, orphanedMember } from '../mock_data';
import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
describe('MemberList', () => {
let wrapper;
const { user } = member;
const createComponent = (propsData = {}) => {
wrapper = mount(UserAvatar, {
propsData: {
member,
...propsData,
},
});
};
const getByText = (text, options) =>
createWrapper(getByTextHelper(wrapper.element, text, options));
afterEach(() => {
wrapper.destroy();
});
it("renders link to user's profile", () => {
createComponent();
const link = wrapper.find(GlAvatarLink);
expect(link.exists()).toBe(true);
expect(link.attributes()).toMatchObject({
href: user.webUrl,
'data-user-id': `${user.id}`,
'data-username': user.username,
});
});
it("renders user's name", () => {
createComponent();
expect(getByText(user.name).exists()).toBe(true);
});
it("renders user's username", () => {
createComponent();
expect(getByText(`@${user.username}`).exists()).toBe(true);
});
it("renders user's avatar", () => {
createComponent();
expect(wrapper.find('img').attributes('src')).toBe(user.avatarUrl);
});
describe('when user property does not exist', () => {
it('displays an orphaned user', () => {
createComponent({ member: orphanedMember });
expect(getByText('Orphaned member').exists()).toBe(true);
});
});
});
export const member = {
requestedAt: null,
canUpdate: false,
canRemove: false,
canOverride: false,
accessLevel: { integerValue: 50, stringValue: 'Owner' },
source: {
id: 178,
name: 'Foo Bar',
webUrl: 'https://gitlab.com/groups/foo-bar',
},
user: {
id: 123,
name: 'Administrator',
username: 'root',
webUrl: 'https://gitlab.com/root',
avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon',
blocked: false,
twoFactorEnabled: false,
},
id: 238,
createdAt: '2020-07-17T16:22:46.923Z',
expiresAt: null,
usingLicense: false,
groupSso: false,
groupManagedAccount: false,
};
export const group = {
accessLevel: { integerValue: 10, stringValue: 'Guest' },
sharedWithGroup: {
id: 24,
name: 'Commit451',
avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png?width=40',
fullPath: 'parent-group/commit451',
fullName: 'Parent group / Commit451',
webUrl: 'https://gitlab.com/groups/parent-group/commit451',
},
id: 3,
createdAt: '2020-08-06T15:31:07.662Z',
expiresAt: null,
};
const { user, ...memberNoUser } = member;
export const invite = {
...memberNoUser,
invite: {
email: 'jewel@hudsonwalter.biz',
avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon',
canResend: true,
},
};
export const orphanedMember = memberNoUser;
export const accessRequest = {
...member,
requestedAt: '2020-07-17T16:22:46.923Z',
};
export const members = [member];
import { shallowMount } from '@vue/test-utils';
import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
import { member as memberMock, group, invite, accessRequest } from '../mock_data';
import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
describe('MemberList', () => {
let wrapper;
const createComponent = propsData => {
wrapper = shallowMount(MemberAvatar, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
});
test.each`
memberType | member | expectedComponent | expectedComponentName
${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'}
${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'}
${MEMBER_TYPES.invite} | ${invite} | ${InviteAvatar} | ${'InviteAvatar'}
${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${UserAvatar} | ${'UserAvatar'}
`(
'renders $expectedComponentName when `memberType` is $memberType',
({ memberType, member, expectedComponent }) => {
createComponent({ memberType, member });
expect(wrapper.find(expectedComponent).exists()).toBe(true);
},
);
});
import { mount, createLocalVue } from '@vue/test-utils';
import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
import { member as memberMock, group, invite, accessRequest } from '../mock_data';
import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue';
describe('MemberList', () => {
const WrappedComponent = {
props: {
memberType: {
type: String,
required: true,
},
},
render(createElement) {
return createElement('div', this.memberType);
},
};
const localVue = createLocalVue();
localVue.component('wrapped-component', WrappedComponent);
let wrapper;
const createComponent = propsData => {
wrapper = mount(MembersTableCell, {
localVue,
propsData,
scopedSlots: {
default: '<wrapped-component :member-type="props.memberType" />',
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
test.each`
member | expectedMemberType
${memberMock} | ${MEMBER_TYPES.user}
${group} | ${MEMBER_TYPES.group}
${invite} | ${MEMBER_TYPES.invite}
${accessRequest} | ${MEMBER_TYPES.accessRequest}
`(
'sets scoped slot prop `memberType` to $expectedMemberType',
({ member, expectedMemberType }) => {
createComponent({ member });
expect(wrapper.find(WrappedComponent).props('memberType')).toBe(expectedMemberType);
},
);
});
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