Commit 7a19b738 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'ph/emojiPicker' into 'master'

Improved award emoji picker [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!54469
parents c4bfd1a1 77292861
......@@ -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 { EMOJIS_PER_ROW } from '../constants';
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),
EMOJIS_PER_ROW,
);
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_ICON_MAP } from '../constants';
import Category from './category.vue';
import EmojiList from './emoji_list.vue';
import { getEmojiCategories } from './utils';
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';
import { EMOJIS_PER_ROW, EMOJI_ROW_HEIGHT, CATEGORY_ROW_HEIGHT } from '../constants';
export const generateCategoryHeight = (emojisLength) =>
emojisLength * EMOJI_ROW_HEIGHT + CATEGORY_ROW_HEIGHT;
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], EMOJIS_PER_ROW);
const height = generateCategoryHeight(emojis.length);
const newAcc = {
...acc,
[category]: { emojis, height, top },
};
top += height;
return newAcc;
}, {}),
);
});
export const CATEGORY_ICON_MAP = {
activity: 'dumbbell',
people: 'smiley',
nature: 'nature',
food: 'food',
travel: 'car',
objects: 'object',
symbols: 'heart',
flags: 'flag',
};
export const EMOJIS_PER_ROW = 9;
export const EMOJI_ROW_HEIGHT = 34;
export const CATEGORY_ROW_HEIGHT = 37;
......@@ -2,6 +2,7 @@ import { escape, minBy } from 'lodash';
import emojiAliases from 'emojis/aliases.json';
import AccessorUtilities from '../lib/utils/accessor';
import axios from '../lib/utils/axios_utils';
import { CATEGORY_ICON_MAP } from './constants';
let emojiMap = null;
let validEmojiNames = null;
......@@ -155,19 +156,14 @@ export function sortEmoji(items) {
return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue));
}
export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP);
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,28 +281,41 @@ export default {
class="line-resolve-btn note-action-button"
@click="onResolve"
/>
<gl-button
v-if="canAwardEmoji"
v-gl-tooltip
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji"
category="tertiary"
variant="default"
size="small"
title="Add reaction"
data-position="right"
:aria-label="__('Add reaction')"
>
<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>
</gl-button>
<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-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"
category="tertiary"
variant="default"
size="small"
title="Add reaction"
data-position="right"
:aria-label="__('Add reaction')"
>
<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>
</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,26 @@ gl-emoji {
vertical-align: baseline;
}
}
.emoji-picker-category-header {
@include gl-sticky;
background-color: $white-transparent;
}
.emoji-picker-emoji {
height: 30px;
// Create a width that fits 9 emojis per row
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
end
page.within('.emoji-menu-content') do
find('gl-emoji[data-name="8ball"]').click
find('.note-emoji-button').click
end
find('gl-emoji[data-name="8ball"]').click
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();
});
beforeEach(() => {
factory({
category: 'Activity',
emojis: [['thumbsup'], ['thumbsdown']],
});
});
it('renders emoji groups', () => {
expect(wrapper.findAll(EmojiGroup).length).toBe(2);
});
it('renders group', async () => {
await wrapper.setData({ renderGroup: true });
expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true');
});
it('renders group on appear', async () => {
wrapper.find(GlIntersectionObserver).vm.$emit('appear');
await nextTick();
expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true');
});
it('emits appear event on appear', async () => {
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