Commit 33ded12d authored by Phil Hughes's avatar Phil Hughes

Improved award emoji picker

This improves the UX and performance of the award
emoji picker.

Groups the emojis into 'tabs' that are just anchors to the groups.

Renders the list of emoji categories with a virtual
scroller that only renders what is visible.
With this we also only render the visible categories emojis
instead of rendering all emojis.
Doing this reduces the DOM node count a lot.
parent 42db2222
......@@ -12,6 +12,7 @@ import axios from './lib/utils/axios_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { __ } from './locale';
window.axios = axios;
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
......
......@@ -6,3 +6,5 @@ if (process.env.NODE_ENV !== 'production') {
}
Vue.use(GlFeatureFlagsPlugin);
Vue.config.ignoredElements = ['gl-emoji'];
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import EmojiGroup from './emoji_group.vue';
export default {
components: {
GlIntersectionObserver,
EmojiGroup,
},
props: {
category: {
type: String,
required: true,
},
emojis: {
type: Array,
required: true,
},
},
data() {
return {
renderGroup: false,
};
},
computed: {
categoryTitle() {
return capitalizeFirstCharacter(this.category);
},
},
methods: {
categoryAppeared() {
this.renderGroup = true;
this.$emit('appear', this.category);
},
categoryDissappeared() {
this.renderGroup = false;
},
},
};
</script>
<template>
<gl-intersection-observer class="gl-px-5 gl-h-full" @appear="categoryAppeared">
<div class="gl-top-0 gl-py-3 gl-w-full emoji-picker-category-header">
<b>{{ categoryTitle }}</b>
</div>
<template v-if="emojis.length">
<emoji-group
v-for="(emojiGroup, index) in emojis"
:key="index"
:emojis="emojiGroup"
:render-group="renderGroup"
:click-emoji="(emoji) => $emit('click', emoji)"
/>
</template>
<p v-else>
{{ s__('AwardEmoji|No emojis found.') }}
</p>
</gl-intersection-observer>
</template>
<script>
export default {
props: {
emojis: {
type: Array,
required: true,
},
renderGroup: {
type: Boolean,
required: true,
},
clickEmoji: {
type: Function,
required: true,
},
},
};
</script>
<template functional>
<div class="gl-display-flex gl-flex-wrap gl-mb-2">
<template v-if="props.renderGroup">
<button
v-for="emoji in props.emojis"
:key="emoji"
type="button"
class="gl-border-0 gl-bg-transparent gl-px-0 gl-py-2 gl-text-center emoji-picker-emoji"
data-testid="emoji-button"
@click="props.clickEmoji(emoji)"
>
<gl-emoji :data-name="emoji" />
</button>
</template>
</div>
</template>
<script>
import { chunk } from 'lodash';
import { searchEmoji } from '~/emoji';
import { getEmojiCategories, generateCategoryHeight } from './utils';
export default {
props: {
searchValue: {
type: String,
required: true,
},
},
data() {
return { render: false };
},
computed: {
filteredCategories() {
if (this.searchValue !== '') {
const emojis = chunk(
searchEmoji(this.searchValue).map(({ emoji }) => emoji.name),
9,
);
return {
search: { emojis, height: generateCategoryHeight(emojis.length) },
};
}
return this.categories;
},
},
async mounted() {
this.categories = await getEmojiCategories();
this.render = true;
},
};
</script>
<template>
<div v-if="render">
<slot :filtered-categories="filteredCategories"></slot>
</div>
</template>
<script>
import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import VirtualList from 'vue-virtual-scroll-list';
import { CATEGORY_NAMES } from '~/emoji';
import Category from './category.vue';
import EmojiList from './emoji_list.vue';
import { getEmojiCategories } from './utils';
const CATEGORY_ICON_MAP = {
activity: 'dumbbell',
people: 'smiley',
nature: 'nature',
food: 'food',
travel: 'car',
objects: 'object',
symbols: 'heart',
flags: 'flag',
};
export default {
components: {
GlIcon,
GlDropdown,
GlSearchBoxByType,
VirtualList,
Category,
EmojiList,
},
props: {
toggleClass: {
type: [Array, String, Object],
required: false,
default: () => [],
},
},
data() {
return {
currentCategory: null,
searchValue: '',
};
},
computed: {
categoryNames() {
return CATEGORY_NAMES.map((category) => ({
name: category,
icon: CATEGORY_ICON_MAP[category],
}));
},
},
methods: {
categoryAppeared(category) {
this.currentCategory = category;
},
async scrollToCategory(categoryName) {
const categories = await getEmojiCategories();
const { top } = categories[categoryName];
this.$refs.virtualScoller.setScrollTop(top);
},
selectEmoji(name) {
this.$emit('click', name);
this.$refs.dropdown.hide();
},
getBoundaryElement() {
return document.querySelector('.content-wrapper') || 'scrollParent';
},
onSearchInput() {
this.$refs.virtualScoller.setScrollTop(0);
this.$refs.virtualScoller.forceRender();
},
},
};
</script>
<template>
<div class="emoji-picker">
<gl-dropdown
ref="dropdown"
:toggle-class="toggleClass"
:boundary="getBoundaryElement()"
menu-class="dropdown-extended-height"
no-flip
right
lazy
>
<template #button-content><slot name="button-content"></slot></template>
<gl-search-box-by-type
v-model="searchValue"
class="gl-mx-5! gl-mb-2!"
autofocus
debounce="500"
@input="onSearchInput"
/>
<div
v-show="!searchValue"
class="gl-display-flex gl-mx-5 gl-border-b-solid gl-border-gray-100 gl-border-b-1"
>
<button
v-for="category in categoryNames"
:key="category.name"
:class="{
'gl-text-black-normal! emoji-picker-category-active': category.name === currentCategory,
}"
type="button"
class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab"
@click="scrollToCategory(category.name)"
>
<gl-icon :name="category.icon" :size="12" />
</button>
</div>
<emoji-list :search-value="searchValue">
<template #default="{ filteredCategories }">
<virtual-list ref="virtualScoller" :size="258" :remain="1" :bench="2" variable>
<div
v-for="(category, categoryKey) in filteredCategories"
:key="categoryKey"
:style="{ height: category.height + 'px' }"
>
<category
:category="categoryKey"
:emojis="category.emojis"
@appear="categoryAppeared"
@click="selectEmoji"
/>
</div>
</virtual-list>
</template>
</emoji-list>
</gl-dropdown>
</div>
</template>
import { chunk, memoize } from 'lodash';
import { initEmojiMap, getEmojiCategoryMap } from '~/emoji';
export const generateCategoryHeight = (emojisLength) => emojisLength * 34 + 29;
export const getEmojiCategories = memoize(async () => {
await initEmojiMap();
const categories = await getEmojiCategoryMap();
let top = 0;
return Object.freeze(
Object.keys(categories).reduce((acc, category) => {
const emojis = chunk(categories[category], 9);
const height = generateCategoryHeight(emojis.length);
const newAcc = {
...acc,
[category]: { emojis, height, top },
};
top += height;
return newAcc;
}, {}),
);
});
......@@ -155,19 +155,23 @@ export function sortEmoji(items) {
return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue));
}
export const CATEGORY_NAMES = [
'activity',
'people',
'nature',
'food',
'travel',
'objects',
'symbols',
'flags',
];
let emojiCategoryMap;
export function getEmojiCategoryMap() {
if (!emojiCategoryMap) {
emojiCategoryMap = {
activity: [],
people: [],
nature: [],
food: [],
travel: [],
objects: [],
symbols: [],
flags: [],
};
emojiCategoryMap = CATEGORY_NAMES.reduce((acc, category) => {
return { ...acc, [category]: [] };
}, {});
Object.keys(emojiMap).forEach((name) => {
const emoji = emojiMap[name];
if (emojiCategoryMap[emoji.c]) {
......
<script>
import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import { deprecatedCreateFlash as flash } from '~/flash';
......@@ -8,6 +8,7 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { splitCamelCase } from '../../lib/utils/text_utility';
import ReplyButton from './note_actions/reply_button.vue';
......@@ -19,11 +20,12 @@ export default {
GlButton,
GlDropdownItem,
UserAccessRoleBadge,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [resolvedStatusMixin],
mixins: [resolvedStatusMixin, glFeatureFlagsMixin()],
props: {
author: {
type: Object,
......@@ -117,6 +119,10 @@ export default {
type: Boolean,
required: true,
},
awardPath: {
type: String,
required: true,
},
},
computed: {
...mapGetters(['getUserDataByProp', 'getNoteableData']),
......@@ -185,6 +191,7 @@ export default {
},
},
methods: {
...mapActions(['toggleAwardRequest']),
onEdit() {
this.$emit('handleEdit');
},
......@@ -222,6 +229,13 @@ export default {
.catch(() => flash(__('Something went wrong while updating assignees')));
}
},
setAwardEmoji(awardName) {
this.toggleAwardRequest({
endpoint: this.awardPath,
noteId: this.noteId,
awardName,
});
},
},
};
</script>
......@@ -267,8 +281,20 @@ export default {
class="line-resolve-btn note-action-button"
@click="onResolve"
/>
<template v-if="canAwardEmoji">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-2 gl-p-0! gl-shadow-none! gl-bg-transparent!"
@click="setAwardEmoji"
>
<template #button-content>
<gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" />
<gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" />
<gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" />
</template>
</emoji-picker>
<gl-button
v-if="canAwardEmoji"
v-else
v-gl-tooltip
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji"
......@@ -289,6 +315,7 @@ export default {
<gl-icon name="smile" />
</span>
</gl-button>
</template>
<reply-button
v-if="showReply"
ref="replyButton"
......
......@@ -416,6 +416,7 @@ export default {
:is-draft="note.isDraft"
:resolve-discussion="note.isDraft && note.resolve_discussion"
:discussion-id="discussionId"
:award-path="note.toggle_award_path"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
......
......@@ -2,7 +2,9 @@
/* eslint-disable vue/no-v-html */
import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { groupBy } from 'lodash';
import EmojiPicker from '~/emoji/components/picker.vue';
import { __, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { glEmojiTag } from '../../emoji';
// Internal constant, specific to this component, used when no `currentUserId` is given
......@@ -12,10 +14,12 @@ export default {
components: {
GlButton,
GlIcon,
EmojiPicker,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
awards: {
type: Array,
......@@ -166,7 +170,25 @@ export default {
<span class="js-counter">{{ awardList.list.length }}</span>
</gl-button>
<div v-if="canAwardEmoji" class="award-menu-holder">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
toggle-class="add-reaction-button gl-relative!"
@click="handleAward"
>
<template #button-content>
<span class="reaction-control-icon reaction-control-icon-neutral">
<gl-icon name="slight-smile" />
</span>
<span class="reaction-control-icon reaction-control-icon-positive">
<gl-icon name="smiley" />
</span>
<span class="reaction-control-icon reaction-control-icon-super-positive">
<gl-icon name="smile" />
</span>
</template>
</emoji-picker>
<gl-button
v-else
v-gl-tooltip.viewport
:class="addButtonClass"
class="add-reaction-button js-add-award"
......
......@@ -274,7 +274,9 @@
// `position:absolute`
&::after {
content: '\a0';
display: block !important;
width: 1em;
color: transparent;
}
.reaction-control-icon {
......
......@@ -16,3 +16,25 @@ gl-emoji {
vertical-align: baseline;
}
}
.emoji-picker-category-header {
@include gl-sticky;
background-color: $white-transparent;
}
.emoji-picker-emoji {
height: 30px;
width: 100 / 9 * 1%;
}
.emoji-picker .gl-new-dropdown .dropdown-menu {
width: 350px;
}
.emoji-picker-category-tab {
border-bottom-color: transparent;
}
.emoji-picker .gl-new-dropdown-inner > :last-child {
padding-bottom: 0;
}
......@@ -205,6 +205,10 @@
}
}
.emoji-picker-category-active {
border-bottom-color: $active-tab-border;
}
.branch-header-title {
color: $border-and-box-shadow;
}
......
......@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
end
before_action only: :show do
......
---
name: improved_emoji_picker
introduced_by_url:
rollout_issue_url:
milestone: '13.9'
type: development
group: group::code review
default_enabled: false
......@@ -4534,6 +4534,9 @@ msgstr ""
msgid "Average per day: %{average}"
msgstr ""
msgid "AwardEmoji|No emojis found."
msgstr ""
msgid "Back to page %{number}"
msgstr ""
......
......@@ -135,11 +135,9 @@ RSpec.describe 'User interacts with awards' do
it 'allows adding a new emoji' do
page.within('.note-actions') do
find('.btn.js-add-award').click
find('.note-emoji-button').click
end
page.within('.emoji-menu-content') do
find('gl-emoji[data-name="8ball"]').click
end
wait_for_requests
page.within('.note-awards') do
......
import { GlIntersectionObserver } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Category from '~/emoji/components/category.vue';
import EmojiGroup from '~/emoji/components/emoji_group.vue';
let wrapper;
function factory(propsData = {}) {
wrapper = shallowMount(Category, { propsData });
}
describe('Emoji category component', () => {
afterEach(() => {
wrapper.destroy();
});
it('renders emoji groups', () => {
factory({
category: 'Activity',
emojis: [['thumbsup'], ['thumbsdown']],
});
expect(wrapper.findAll(EmojiGroup).length).toBe(2);
});
it('renders group', async () => {
factory({
category: 'Activity',
emojis: [['thumbsup'], ['thumbsdown']],
});
await wrapper.setData({ renderGroup: true });
expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true');
});
it('renders group on appear', async () => {
factory({
category: 'Activity',
emojis: [['thumbsup'], ['thumbsdown']],
});
wrapper.find(GlIntersectionObserver).vm.$emit('appear');
await nextTick();
expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true');
});
it('emits appear event on appear', async () => {
factory({
category: 'Activity',
emojis: [['thumbsup'], ['thumbsdown']],
});
wrapper.find(GlIntersectionObserver).vm.$emit('appear');
await nextTick();
expect(wrapper.emitted().appear[0]).toEqual(['Activity']);
});
});
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import EmojiGroup from '~/emoji/components/emoji_group.vue';
Vue.config.ignoredElements = ['gl-emoji'];
let wrapper;
function factory(propsData = {}) {
wrapper = extendedWrapper(
shallowMount(EmojiGroup, {
propsData,
}),
);
}
describe('Emoji group component', () => {
afterEach(() => {
wrapper.destroy();
});
it('does not render any buttons', () => {
factory({
emojis: [],
renderGroup: false,
clickEmoji: jest.fn(),
});
expect(wrapper.findByTestId('emoji-button').exists()).toBe(false);
});
it('renders emojis', () => {
factory({
emojis: ['thumbsup', 'thumbsdown'],
renderGroup: true,
clickEmoji: jest.fn(),
});
expect(wrapper.findAllByTestId('emoji-button').exists()).toBe(true);
expect(wrapper.findAllByTestId('emoji-button').length).toBe(2);
});
it('calls clickEmoji', () => {
const clickEmoji = jest.fn();
factory({
emojis: ['thumbsup', 'thumbsdown'],
renderGroup: true,
clickEmoji,
});
wrapper.findByTestId('emoji-button').trigger('click');
expect(clickEmoji).toHaveBeenCalledWith('thumbsup');
});
});
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import EmojiList from '~/emoji/components/emoji_list.vue';
jest.mock('~/emoji', () => ({
initEmojiMap: jest.fn(() => Promise.resolve()),
searchEmoji: jest.fn((search) => [{ emoji: { name: search } }]),
getEmojiCategoryMap: jest.fn(() =>
Promise.resolve({
activity: ['thumbsup', 'thumbsdown'],
}),
),
}));
let wrapper;
async function factory(render, propsData = { searchValue: '' }) {
wrapper = extendedWrapper(
shallowMount(EmojiList, {
propsData,
scopedSlots: {
default: '<div data-testid="default-slot">{{props.filteredCategories}}</div>',
},
}),
);
// Wait for categories to be set
await nextTick();
if (render) {
wrapper.setData({ render: true });
// Wait for component to render
await nextTick();
}
}
const findDefaultSlot = () => wrapper.findByTestId('default-slot');
describe('Emoji list component', () => {
afterEach(() => {
wrapper.destroy();
});
it('does not render until render is set', async () => {
await factory(false);
expect(findDefaultSlot().exists()).toBe(false);
});
it('renders with none filtered list', async () => {
await factory(true);
expect(JSON.parse(findDefaultSlot().text())).toEqual({
activity: {
emojis: [['thumbsup', 'thumbsdown']],
height: expect.any(Number),
top: expect.any(Number),
},
});
});
it('renders filtered list of emojis', async () => {
await factory(true, { searchValue: 'smile' });
expect(JSON.parse(findDefaultSlot().text())).toEqual({
search: {
emojis: [['smile']],
height: expect.any(Number),
},
});
});
});
......@@ -48,6 +48,7 @@ describe('noteActions', () => {
projectName: 'project',
reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`,
showReply: false,
awardPath: `${TEST_HOST}/award_emoji`,
};
actions = {
......
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