Commit ad46b6cf authored by Denys Mishunov's avatar Denys Mishunov

Merge branch '55650-refactor-notes-awards-components-for-reusability' into 'master'

Decouple presentation of note_awards_list

See merge request gitlab-org/gitlab!29528
parents 5f0e5223 074964ed
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip'; import AwardsList from '~/vue_shared/components/awards_list.vue';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash'; import Flash from '../../flash';
import { glEmojiTag } from '../../emoji'; import { __ } from '~/locale';
import { __, sprintf } from '~/locale';
export default { export default {
components: { components: {
Icon, AwardsList,
},
directives: {
tooltip,
}, },
props: { props: {
awards: { awards: {
...@@ -37,130 +32,20 @@ export default { ...@@ -37,130 +32,20 @@ export default {
}, },
computed: { computed: {
...mapGetters(['getUserData']), ...mapGetters(['getUserData']),
// `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
// [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
// This method will group emojis by their name as an Object. See below.
// {
// foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
// bar: [ { name: bar, user: user1 } ]
// }
// We need to do this otherwise we will render the same emoji over and over again.
groupedAwards() {
const awards = this.awards.reduce((acc, award) => {
if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
acc[award.name].push(award);
} else {
Object.assign(acc, { [award.name]: [award] });
}
return acc;
}, {});
const orderedAwards = {};
const { thumbsdown, thumbsup } = awards;
// Always show thumbsup and thumbsdown first
if (thumbsup) {
orderedAwards.thumbsup = thumbsup;
delete awards.thumbsup;
}
if (thumbsdown) {
orderedAwards.thumbsdown = thumbsdown;
delete awards.thumbsdown;
}
return Object.assign({}, orderedAwards, awards);
},
isAuthoredByMe() { isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id; return this.noteAuthorId === this.getUserData.id;
}, },
addButtonClass() {
return this.isAuthoredByMe ? 'js-user-authored' : '';
},
}, },
methods: { methods: {
...mapActions(['toggleAwardRequest']), ...mapActions(['toggleAwardRequest']),
getAwardHTML(name) {
return glEmojiTag(name);
},
getAwardClassBindings(awardList) {
return {
active: this.hasReactionByCurrentUser(awardList),
disabled: !this.canInteractWithEmoji(),
};
},
canInteractWithEmoji() {
return this.getUserData.id;
},
hasReactionByCurrentUser(awardList) {
return awardList.filter(award => award.user.id === this.getUserData.id).length;
},
awardTitle(awardsList) {
const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
let awardList = awardsList;
// Filter myself from list if I am awarded.
if (hasReactionByCurrentUser) {
awardList = awardList.filter(award => award.user.id !== this.getUserData.id);
}
// Get only 9-10 usernames to show in tooltip text.
const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
// Get the remaining list to use in `and x more` text.
const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
// Add myself to the beginning of the list so title will start with You.
if (hasReactionByCurrentUser) {
namesToShow.unshift(__('You'));
}
let title = '';
// We have 10+ awarded user, join them with comma and add `and x more`.
if (remainingAwardList.length) {
title = sprintf(
__(`%{listToShow}, and %{awardsListLength} more.`),
{
listToShow: namesToShow.join(', '),
awardsListLength: remainingAwardList.length,
},
false,
);
} else if (namesToShow.length > 1) {
// Join all names with comma but not the last one, it will be added with and text.
title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
// If we have more than 2 users we need an extra comma before and text.
title += namesToShow.length > 2 ? ',' : '';
title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text
} else {
// We have only 2 users so join them with and.
title = namesToShow.join(__(' and '));
}
return title;
},
handleAward(awardName) { handleAward(awardName) {
if (!this.canAwardEmoji) {
return;
}
let parsedName;
// 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
switch (awardName) {
case '100':
parsedName = 100;
break;
case '1234':
parsedName = 1234;
break;
default:
parsedName = awardName;
break;
}
const data = { const data = {
endpoint: this.toggleAwardPath, endpoint: this.toggleAwardPath,
noteId: this.noteId, noteId: this.noteId,
awardName: parsedName, awardName,
}; };
this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.'))); this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.')));
...@@ -171,46 +56,12 @@ export default { ...@@ -171,46 +56,12 @@ export default {
<template> <template>
<div class="note-awards"> <div class="note-awards">
<div class="awards js-awards-block"> <awards-list
<button :awards="awards"
v-for="(awardList, awardName, index) in groupedAwards" :can-award-emoji="canAwardEmoji"
:key="index" :current-user-id="getUserData.id"
v-tooltip :add-button-class="addButtonClass"
:class="getAwardClassBindings(awardList)" @award="handleAward($event)"
:title="awardTitle(awardList)" />
data-boundary="viewport"
class="btn award-control"
type="button"
@click="handleAward(awardName)"
>
<span v-html="getAwardHTML(awardName)"></span>
<span class="award-control-text js-counter">{{ awardList.length }}</span>
</button>
<div v-if="canAwardEmoji" class="award-menu-holder">
<button
v-tooltip
:class="{ 'js-user-authored': isAuthoredByMe }"
class="award-control btn js-add-award"
title="Add reaction"
:aria-label="__('Add reaction')"
data-boundary="viewport"
type="button"
>
<span class="award-control-icon award-control-icon-neutral">
<icon name="slight-smile" />
</span>
<span class="award-control-icon award-control-icon-positive">
<icon name="smiley" />
</span>
<span class="award-control-icon award-control-icon-super-positive">
<icon name="smiley" />
</span>
<i
aria-hidden="true"
class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
></i>
</button>
</div>
</div>
</div> </div>
</template> </template>
<script>
import { groupBy } from 'lodash';
import { GlIcon } from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
import { glEmojiTag } from '../../emoji';
import { __, sprintf } from '~/locale';
// Internal constant, specific to this component, used when no `currentUserId` is given
const NO_USER_ID = -1;
export default {
components: {
GlIcon,
},
directives: {
tooltip,
},
props: {
awards: {
type: Array,
required: true,
},
canAwardEmoji: {
type: Boolean,
required: true,
},
currentUserId: {
type: Number,
required: false,
default: NO_USER_ID,
},
addButtonClass: {
type: String,
required: false,
default: '',
},
},
computed: {
groupedAwards() {
const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name);
return [
...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []),
...(thumbsdown ? [this.createAwardList('thumbsdown', thumbsdown)] : []),
...Object.entries(rest).map(([name, list]) => this.createAwardList(name, list)),
];
},
isAuthoredByMe() {
return this.noteAuthorId === this.currentUserId;
},
},
methods: {
getAwardClassBindings(awardList) {
return {
active: this.hasReactionByCurrentUser(awardList),
disabled: this.currentUserId === NO_USER_ID,
};
},
hasReactionByCurrentUser(awardList) {
if (this.currentUserId === NO_USER_ID) {
return false;
}
return awardList.some(award => award.user.id === this.currentUserId);
},
createAwardList(name, list) {
return {
name,
list,
title: this.getAwardListTitle(list),
classes: this.getAwardClassBindings(list),
html: glEmojiTag(name),
};
},
getAwardListTitle(awardsList) {
const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
let awardList = awardsList;
// Filter myself from list if I am awarded.
if (hasReactionByCurrentUser) {
awardList = awardList.filter(award => award.user.id !== this.currentUserId);
}
// Get only 9-10 usernames to show in tooltip text.
const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
// Get the remaining list to use in `and x more` text.
const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
// Add myself to the beginning of the list so title will start with You.
if (hasReactionByCurrentUser) {
namesToShow.unshift(__('You'));
}
let title = '';
// We have 10+ awarded user, join them with comma and add `and x more`.
if (remainingAwardList.length) {
title = sprintf(
__(`%{listToShow}, and %{awardsListLength} more.`),
{
listToShow: namesToShow.join(', '),
awardsListLength: remainingAwardList.length,
},
false,
);
} else if (namesToShow.length > 1) {
// Join all names with comma but not the last one, it will be added with and text.
title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
// If we have more than 2 users we need an extra comma before and text.
title += namesToShow.length > 2 ? ',' : '';
title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text
} else {
// We have only 2 users so join them with and.
title = namesToShow.join(__(' and '));
}
return title;
},
handleAward(awardName) {
if (!this.canAwardEmoji) {
return;
}
// 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName;
this.$emit('award', parsedName);
},
},
};
</script>
<template>
<div class="awards js-awards-block">
<button
v-for="awardList in groupedAwards"
:key="awardList.name"
v-tooltip
:class="awardList.classes"
:title="awardList.title"
data-boundary="viewport"
data-testid="award-button"
class="btn award-control"
type="button"
@click="handleAward(awardList.name)"
>
<span data-testid="award-html" v-html="awardList.html"></span>
<span class="award-control-text js-counter">{{ awardList.list.length }}</span>
</button>
<div v-if="canAwardEmoji" class="award-menu-holder">
<button
v-tooltip
:class="addButtonClass"
class="award-control btn js-add-award"
title="Add reaction"
:aria-label="__('Add reaction')"
data-boundary="viewport"
type="button"
>
<span class="award-control-icon award-control-icon-neutral">
<gl-icon aria-hidden="true" name="slight-smile" />
</span>
<span class="award-control-icon award-control-icon-positive">
<gl-icon aria-hidden="true" name="smiley" />
</span>
<span class="award-control-icon award-control-icon-super-positive">
<gl-icon aria-hidden="true" name="smiley" />
</span>
<i
aria-hidden="true"
class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
></i>
</button>
</div>
</div>
</template>
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<div
class="awards js-awards-block"
>
<button
class="btn award-control"
data-boundary="viewport"
data-original-title="Ada, Leonardo, and Marie"
data-testid="award-button"
title=""
type="button"
>
<span
data-testid="award-html"
>
<gl-emoji
data-fallback-src="/assets/emoji/thumbsup-59ec2457ab33e8897261d01a495f6cf5c668d0004807dc541c3b1be5294b1e61.png"
data-name="thumbsup"
data-unicode-version="6.0"
title="thumbs up sign"
>
👍
</gl-emoji>
</span>
<span
class="award-control-text js-counter"
>
3
</span>
</button>
<button
class="btn award-control active"
data-boundary="viewport"
data-original-title="You, Ada, and Marie"
data-testid="award-button"
title=""
type="button"
>
<span
data-testid="award-html"
>
<gl-emoji
data-fallback-src="/assets/emoji/thumbsdown-5954334e2dae5357312b3d629f10a496c728029e02216f8c8b887f9b51561c61.png"
data-name="thumbsdown"
data-unicode-version="6.0"
title="thumbs down sign"
>
👎
</gl-emoji>
</span>
<span
class="award-control-text js-counter"
>
3
</span>
</button>
<button
class="btn award-control"
data-boundary="viewport"
data-original-title="Ada and Jane"
data-testid="award-button"
title=""
type="button"
>
<span
data-testid="award-html"
>
<gl-emoji
data-fallback-src="/assets/emoji/smile-14905c372d5bf7719bd727c9efae31a03291acec79801652a23710c6848c5d14.png"
data-name="smile"
data-unicode-version="6.0"
title="smiling face with open mouth and smiling eyes"
>
😄
</gl-emoji>
</span>
<span
class="award-control-text js-counter"
>
2
</span>
</button>
<button
class="btn award-control active"
data-boundary="viewport"
data-original-title="You, Ada, Jane, and Leonardo"
data-testid="award-button"
title=""
type="button"
>
<span
data-testid="award-html"
>
<gl-emoji
data-fallback-src="/assets/emoji/ok_hand-d63002dce3cc3655b67b8765b7c28d370edba0e3758b2329b60e0e61c4d8e78d.png"
data-name="ok_hand"
data-unicode-version="6.0"
title="ok hand sign"
>
👌
</gl-emoji>
</span>
<span
class="award-control-text js-counter"
>
4
</span>
</button>
<button
class="btn award-control active"
data-boundary="viewport"
data-original-title="You"
data-testid="award-button"
title=""
type="button"
>
<span
data-testid="award-html"
>
<gl-emoji
data-fallback-src="/assets/emoji/cactus-2c5c4c35f26c7046fdc002b337e0d939729b33a26980e675950f9934c91e40fd.png"
data-name="cactus"
data-unicode-version="6.0"
title="cactus"
>
🌵
</gl-emoji>
</span>
<span
class="award-control-text js-counter"
>
1
</span>
</button>
<button
class="btn award-control"
data-boundary="viewport"
data-original-title="Marie"
data-testid="award-button"
title=""
type="button"
>
<span
data-testid="award-html"
>
<gl-emoji
data-fallback-src="/assets/emoji/a-bddbb39e8a1d35d42b7c08e7d47f63988cb4d8614b79f74e70b9c67c221896cc.png"
data-name="a"
data-unicode-version="6.0"
title="negative squared latin capital letter a"
>
🅰
</gl-emoji>
</span>
<span
class="award-control-text js-counter"
>
1
</span>
</button>
<button
class="btn award-control active"
data-boundary="viewport"
data-original-title="You"
data-testid="award-button"
title=""
type="button"
>
<span
data-testid="award-html"
>
<gl-emoji
data-fallback-src="/assets/emoji/b-722f9db9442e7c0fc0d0ac0f5291fbf47c6a0ac4d8abd42e97957da705fb82bf.png"
data-name="b"
data-unicode-version="6.0"
title="negative squared latin capital letter b"
>
🅱
</gl-emoji>
</span>
<span
class="award-control-text js-counter"
>
1
</span>
</button>
<div
class="award-menu-holder"
>
<button
aria-label="Add reaction"
class="award-control btn js-add-award js-test-add-button-class"
data-boundary="viewport"
data-original-title="Add reaction"
title=""
type="button"
>
<span
class="award-control-icon award-control-icon-neutral"
>
<gl-icon-stub
aria-hidden="true"
name="slight-smile"
size="16"
/>
</span>
<span
class="award-control-icon award-control-icon-positive"
>
<gl-icon-stub
aria-hidden="true"
name="smiley"
size="16"
/>
</span>
<span
class="award-control-icon award-control-icon-super-positive"
>
<gl-icon-stub
aria-hidden="true"
name="smiley"
size="16"
/>
</span>
<i
aria-hidden="true"
class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
/>
</button>
</div>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import AwardsList from '~/vue_shared/components/awards_list.vue';
const createUser = (id, name) => ({ id, name });
const createAward = (name, user) => ({ name, user });
const USERS = {
root: createUser(1, 'Root'),
ada: createUser(2, 'Ada'),
marie: createUser(3, 'Marie'),
jane: createUser(4, 'Jane'),
leonardo: createUser(5, 'Leonardo'),
};
const EMOJI_SMILE = 'smile';
const EMOJI_OK = 'ok_hand';
const EMOJI_THUMBSUP = 'thumbsup';
const EMOJI_THUMBSDOWN = 'thumbsdown';
const EMOJI_A = 'a';
const EMOJI_B = 'b';
const EMOJI_CACTUS = 'cactus';
const EMOJI_100 = '100';
const TEST_AWARDS = [
createAward(EMOJI_SMILE, USERS.ada),
createAward(EMOJI_OK, USERS.ada),
createAward(EMOJI_THUMBSUP, USERS.ada),
createAward(EMOJI_THUMBSDOWN, USERS.ada),
createAward(EMOJI_SMILE, USERS.jane),
createAward(EMOJI_OK, USERS.jane),
createAward(EMOJI_OK, USERS.leonardo),
createAward(EMOJI_THUMBSUP, USERS.leonardo),
createAward(EMOJI_THUMBSUP, USERS.marie),
createAward(EMOJI_THUMBSDOWN, USERS.marie),
createAward(EMOJI_THUMBSDOWN, USERS.root),
createAward(EMOJI_OK, USERS.root),
// Test that emoji list preserves order of occurrence, not alphabetical order
createAward(EMOJI_CACTUS, USERS.root),
createAward(EMOJI_A, USERS.marie),
createAward(EMOJI_B, USERS.root),
];
const TEST_ADD_BUTTON_CLASS = 'js-test-add-button-class';
describe('vue_shared/components/awards_list', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const createComponent = (props = {}) => {
if (wrapper) {
throw new Error('There should only be one wrapper created per test');
}
wrapper = shallowMount(AwardsList, { propsData: props });
};
const matchingEmojiTag = name => expect.stringMatching(`gl-emoji data-name="${name}"`);
const findAwardButtons = () => wrapper.findAll('[data-testid="award-button"');
const findAwardsData = () =>
findAwardButtons().wrappers.map(x => {
return {
classes: x.classes(),
title: x.attributes('data-original-title'),
html: x.find('[data-testid="award-html"]').element.innerHTML,
count: Number(x.find('.js-counter').text()),
};
});
const findAddAwardButton = () => wrapper.find('.js-add-award');
describe('default', () => {
beforeEach(() => {
createComponent({
awards: TEST_AWARDS,
canAwardEmoji: true,
currentUserId: USERS.root.id,
addButtonClass: TEST_ADD_BUTTON_CLASS,
});
});
it('matches snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('shows awards in correct order', () => {
expect(findAwardsData()).toEqual([
{
classes: ['btn', 'award-control'],
count: 3,
html: matchingEmojiTag(EMOJI_THUMBSUP),
title: 'Ada, Leonardo, and Marie',
},
{
classes: ['btn', 'award-control', 'active'],
count: 3,
html: matchingEmojiTag(EMOJI_THUMBSDOWN),
title: 'You, Ada, and Marie',
},
{
classes: ['btn', 'award-control'],
count: 2,
html: matchingEmojiTag(EMOJI_SMILE),
title: 'Ada and Jane',
},
{
classes: ['btn', 'award-control', 'active'],
count: 4,
html: matchingEmojiTag(EMOJI_OK),
title: 'You, Ada, Jane, and Leonardo',
},
{
classes: ['btn', 'award-control', 'active'],
count: 1,
html: matchingEmojiTag(EMOJI_CACTUS),
title: 'You',
},
{
classes: ['btn', 'award-control'],
count: 1,
html: matchingEmojiTag(EMOJI_A),
title: 'Marie',
},
{
classes: ['btn', 'award-control', 'active'],
count: 1,
html: matchingEmojiTag(EMOJI_B),
title: 'You',
},
]);
});
it('with award clicked, it emits award', () => {
expect(wrapper.emitted().award).toBeUndefined();
findAwardButtons()
.at(2)
.trigger('click');
expect(wrapper.emitted().award).toEqual([[EMOJI_SMILE]]);
});
it('shows add award button', () => {
const btn = findAddAwardButton();
expect(btn.exists()).toBe(true);
expect(btn.classes(TEST_ADD_BUTTON_CLASS)).toBe(true);
});
});
describe('with numeric award', () => {
beforeEach(() => {
createComponent({
awards: [createAward(EMOJI_100, USERS.ada)],
canAwardEmoji: true,
currentUserId: USERS.root.id,
});
});
it('when clicked, it emits award as number', () => {
expect(wrapper.emitted().award).toBeUndefined();
findAwardButtons()
.at(0)
.trigger('click');
expect(wrapper.emitted().award).toEqual([[Number(EMOJI_100)]]);
});
});
describe('with no awards', () => {
beforeEach(() => {
createComponent({
awards: [],
canAwardEmoji: true,
});
});
it('has no award buttons', () => {
expect(findAwardButtons().length).toBe(0);
});
});
describe('when cannot award emoji', () => {
beforeEach(() => {
createComponent({
awards: [createAward(EMOJI_CACTUS, USERS.root.id)],
canAwardEmoji: false,
currentUserId: USERS.marie.id,
});
});
it('does not have add button', () => {
expect(findAddAwardButton().exists()).toBe(false);
});
});
describe('with no user', () => {
beforeEach(() => {
createComponent({
awards: TEST_AWARDS,
canAwardEmoji: false,
});
});
it('disables award buttons', () => {
const buttons = findAwardButtons();
expect(buttons.length).toBe(7);
expect(buttons.wrappers.every(x => x.classes('disabled'))).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