Commit e46b95a6 authored by Peter Hegman's avatar Peter Hegman Committed by Kushal Pandya

Add badges to member avatars

Part of a larger initiative to convert group members view from HAML
to Vue
parent fa53b1e7
<script> <script>
import { GlAvatarLink, GlAvatarLabeled, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import {
GlAvatarLink,
GlAvatarLabeled,
GlBadge,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { AVATAR_SIZE } from '../constants'; import { AVATAR_SIZE } from '../constants';
...@@ -7,7 +13,11 @@ export default { ...@@ -7,7 +13,11 @@ export default {
name: 'UserAvatar', name: 'UserAvatar',
avatarSize: AVATAR_SIZE, avatarSize: AVATAR_SIZE,
orphanedUserLabel: __('Orphaned member'), orphanedUserLabel: __('Orphaned member'),
components: { GlAvatarLink, GlAvatarLabeled }, components: {
GlAvatarLink,
GlAvatarLabeled,
GlBadge,
},
directives: { directives: {
SafeHtml, SafeHtml,
}, },
...@@ -16,11 +26,18 @@ export default { ...@@ -16,11 +26,18 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
isCurrentUser: {
type: Boolean,
required: true,
},
}, },
computed: { computed: {
user() { user() {
return this.member.user; return this.member.user;
}, },
badges() {
return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show);
},
}, },
}; };
</script> </script>
...@@ -41,7 +58,15 @@ export default { ...@@ -41,7 +58,15 @@ export default {
:size="$options.avatarSize" :size="$options.avatarSize"
:entity-name="user.name" :entity-name="user.name"
:entity-id="user.id" :entity-id="user.id"
/> >
<template #meta>
<div v-for="badge in badges" :key="badge.text" class="gl-p-1">
<gl-badge size="sm" :variant="badge.variant">
{{ badge.text }}
</gl-badge>
</div>
</template>
</gl-avatar-labeled>
</gl-avatar-link> </gl-avatar-link>
<gl-avatar-labeled <gl-avatar-labeled
......
...@@ -12,6 +12,10 @@ export default { ...@@ -12,6 +12,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isCurrentUser: {
type: Boolean,
required: true,
},
member: { member: {
type: Object, type: Object,
required: true, required: true,
...@@ -27,5 +31,5 @@ export default { ...@@ -27,5 +31,5 @@ export default {
</script> </script>
<template> <template>
<component :is="avatarComponent" :member="member" /> <component :is="avatarComponent" :member="member" :is-current-user="isCurrentUser" />
</template> </template>
...@@ -44,8 +44,12 @@ export default { ...@@ -44,8 +44,12 @@ export default {
show-empty show-empty
> >
<template #cell(account)="{ item: member }"> <template #cell(account)="{ item: member }">
<members-table-cell #default="{ memberType }" :member="member"> <members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
<member-avatar :member-type="memberType" :member="member" /> <member-avatar
:member-type="memberType"
:is-current-user="isCurrentUser"
:member="member"
/>
</members-table-cell> </members-table-cell>
</template> </template>
......
...@@ -11,7 +11,7 @@ export default { ...@@ -11,7 +11,7 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['sourceId']), ...mapState(['sourceId', 'currentUserId']),
isGroup() { isGroup() {
return Boolean(this.member.sharedWithGroup); return Boolean(this.member.sharedWithGroup);
}, },
...@@ -35,11 +35,15 @@ export default { ...@@ -35,11 +35,15 @@ export default {
isDirectMember() { isDirectMember() {
return this.member.source?.id === this.sourceId; return this.member.source?.id === this.sourceId;
}, },
isCurrentUser() {
return this.member.user?.id === this.currentUserId;
},
}, },
render() { render() {
return this.$scopedSlots.default({ return this.$scopedSlots.default({
memberType: this.memberType, memberType: this.memberType,
isDirectMember: this.isDirectMember, isDirectMember: this.isDirectMember,
isCurrentUser: this.isCurrentUser,
}); });
}, },
}; };
......
import { __ } from '~/locale';
export const generateBadges = (member, isCurrentUser) => [
{
show: isCurrentUser,
text: __("It's you"),
variant: 'success',
},
{
show: member.user?.blocked,
text: __('Blocked'),
variant: 'danger',
},
{
show: member.user?.twoFactorEnabled,
text: __('2FA'),
variant: 'info',
},
];
import { __ } from '~/locale';
import { generateBadges as CEGenerateBadges } from '~/vue_shared/components/members/utils';
export const generateBadges = (member, isCurrentUser) => [
...CEGenerateBadges(member, isCurrentUser),
{
show: member.usingLicense,
text: __('Is using seat'),
variant: 'neutral',
},
{
show: member.groupSso,
text: __('SAML'),
variant: 'info',
},
{
show: member.groupManagedAccount,
text: __('Managed Account'),
variant: 'info',
},
];
import { member as memberMock } from 'jest/vue_shared/components/members/mock_data';
import { generateBadges } from 'ee/vue_shared/components/members/utils';
describe('Members Utils', () => {
describe('generateBadges', () => {
it('has correct properties for each badge', () => {
const badges = generateBadges(memberMock, true);
badges.forEach(badge => {
expect(badge).toEqual(
expect.objectContaining({
show: expect.any(Boolean),
text: expect.any(String),
variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/),
}),
);
});
});
it.each`
member | expected
${{ ...memberMock, usingLicense: true }} | ${{ show: true, text: 'Is using seat', variant: 'neutral' }}
${{ ...memberMock, groupSso: true }} | ${{ show: true, text: 'SAML', variant: 'info' }}
${{ ...memberMock, groupManagedAccount: true }} | ${{ show: true, text: 'Managed Account', variant: 'info' }}
`('returns expected output for "$expected.text" badge', ({ member, expected }) => {
expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected));
});
});
});
...@@ -22178,6 +22178,9 @@ msgstr "" ...@@ -22178,6 +22178,9 @@ msgstr ""
msgid "Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects." msgid "Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects."
msgstr "" msgstr ""
msgid "SAML"
msgstr ""
msgid "SAML SSO" msgid "SAML SSO"
msgstr "" msgstr ""
......
import { mount, createWrapper } from '@vue/test-utils'; import { mount, createWrapper } from '@vue/test-utils';
import { getByText as getByTextHelper } from '@testing-library/dom'; import { within } from '@testing-library/dom';
import { GlAvatarLink } from '@gitlab/ui'; import { GlAvatarLink, GlBadge } from '@gitlab/ui';
import { member, orphanedMember } from '../mock_data'; import { member as memberMock, orphanedMember } from '../mock_data';
import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
describe('MemberList', () => { describe('MemberList', () => {
let wrapper; let wrapper;
const { user } = member; const { user } = memberMock;
const createComponent = (propsData = {}) => { const createComponent = (propsData = {}) => {
wrapper = mount(UserAvatar, { wrapper = mount(UserAvatar, {
propsData: { propsData: {
member, member: memberMock,
isCurrentUser: false,
...propsData, ...propsData,
}, },
}); });
}; };
const getByText = (text, options) => const getByText = (text, options) =>
createWrapper(getByTextHelper(wrapper.element, text, options)); createWrapper(within(wrapper.element).findByText(text, options));
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -63,4 +64,25 @@ describe('MemberList', () => { ...@@ -63,4 +64,25 @@ describe('MemberList', () => {
expect(getByText('Orphaned member').exists()).toBe(true); expect(getByText('Orphaned member').exists()).toBe(true);
}); });
}); });
describe('badges', () => {
it.each`
member | badgeText
${{ ...memberMock, usingLicense: true }} | ${'Is using seat'}
${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'}
${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'}
${{ ...memberMock, groupSso: true }} | ${'SAML'}
${{ ...memberMock, groupManagedAccount: true }} | ${'Managed Account'}
`('renders the "$badgeText" badge', ({ member, badgeText }) => {
createComponent({ member });
expect(wrapper.find(GlBadge).text()).toBe(badgeText);
});
it('renders the "It\'s you" badge when member is current user', () => {
createComponent({ isCurrentUser: true });
expect(getByText("It's you").exists()).toBe(true);
});
});
}); });
...@@ -11,7 +11,10 @@ describe('MemberList', () => { ...@@ -11,7 +11,10 @@ describe('MemberList', () => {
const createComponent = propsData => { const createComponent = propsData => {
wrapper = shallowMount(MemberAvatar, { wrapper = shallowMount(MemberAvatar, {
propsData, propsData: {
isCurrentUser: false,
...propsData,
},
}); });
}; };
......
...@@ -15,6 +15,10 @@ describe('MemberList', () => { ...@@ -15,6 +15,10 @@ describe('MemberList', () => {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
isCurrentUser: {
type: Boolean,
required: true,
},
}, },
render(createElement) { render(createElement) {
return createElement('div', this.memberType); return createElement('div', this.memberType);
...@@ -29,6 +33,7 @@ describe('MemberList', () => { ...@@ -29,6 +33,7 @@ describe('MemberList', () => {
return new Vuex.Store({ return new Vuex.Store({
state: { state: {
sourceId: 1, sourceId: 1,
currentUserId: 1,
...state, ...state,
}, },
}); });
...@@ -42,8 +47,13 @@ describe('MemberList', () => { ...@@ -42,8 +47,13 @@ describe('MemberList', () => {
propsData, propsData,
store: createStore(state), store: createStore(state),
scopedSlots: { scopedSlots: {
default: default: `
'<wrapped-component :member-type="props.memberType" :is-direct-member="props.isDirectMember" />', <wrapped-component
:member-type="props.memberType"
:is-direct-member="props.isDirectMember"
:is-current-user="props.isCurrentUser"
/>
`,
}, },
}); });
}; };
...@@ -93,4 +103,28 @@ describe('MemberList', () => { ...@@ -93,4 +103,28 @@ describe('MemberList', () => {
expect(findWrappedComponent().props('isDirectMember')).toBe(false); expect(findWrappedComponent().props('isDirectMember')).toBe(false);
}); });
}); });
describe('isCurrentUser', () => {
it('returns `true` when `member.user` has the same ID as `currentUserId`', () => {
createComponent({
member: {
...memberMock,
user: {
...memberMock.user,
id: 1,
},
},
});
expect(findWrappedComponent().props('isCurrentUser')).toBe(true);
});
it('returns `false` when `member.user` does not have the same ID as `currentUserId`', () => {
createComponent({
member: memberMock,
});
expect(findWrappedComponent().props('isCurrentUser')).toBe(false);
});
});
}); });
import { generateBadges } from '~/vue_shared/components/members/utils';
import { member as memberMock } from './mock_data';
describe('Members Utils', () => {
describe('generateBadges', () => {
it('has correct properties for each badge', () => {
const badges = generateBadges(memberMock, true);
badges.forEach(badge => {
expect(badge).toEqual(
expect.objectContaining({
show: expect.any(Boolean),
text: expect.any(String),
variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/),
}),
);
});
});
it.each`
member | expected
${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }}
${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }}
${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }}
`('returns expected output for "$expected.text" badge', ({ member, expected }) => {
expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected));
});
});
});
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