Commit 006e0b67 authored by Phil Hughes's avatar Phil Hughes

Refactors the awards block to use Vue

Converts our awards block which is handled by JS and HAML
to use Vue which allows us to use the new emoji picker.

Closes https://gitlab.com/gitlab-org/gitlab/-/issues/324666
parent 987be4a5
import Vue from 'vue';
import { mapActions, mapState } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import AwardsList from '~/vue_shared/components/awards_list.vue';
import createstore from './store';
export default (el) => {
const {
dataset: { path },
} = el;
const canAwardEmoji = parseBoolean(el.dataset.canAwardEmoji);
return new Vue({
el,
store: createstore(),
computed: {
...mapState(['currentUserId', 'canAwardEmoji', 'awards']),
},
created() {
this.setInitialData({ path, currentUserId: window.gon.current_user_id, canAwardEmoji });
},
mounted() {
this.fetchAwards();
},
methods: {
...mapActions(['setInitialData', 'fetchAwards', 'toggleAward']),
},
render(createElement) {
return createElement(AwardsList, {
props: {
awards: this.awards,
canAwardEmoji: this.canAwardEmoji,
currentUserId: this.currentUserId,
defaultAwards: ['thumbsup', 'thumbsdown'],
selectedClass: 'gl-bg-blue-50! is-active',
},
on: {
award: this.toggleAward,
},
});
},
});
};
import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
import { normalizeHeaders } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import showToast from '~/vue_shared/plugins/global_toast';
import {
SET_INITIAL_DATA,
FETCH_AWARDS_SUCCESS,
ADD_NEW_AWARD,
REMOVE_AWARD,
} from './mutation_types';
export const setInitialData = ({ commit }, data) => commit(SET_INITIAL_DATA, data);
export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => {
try {
const { data, headers } = await axios.get(state.path, { params: { per_page: 100, page } });
const normalizedHeaders = normalizeHeaders(headers);
const nextPage = normalizedHeaders['X-NEXT-PAGE'];
commit(FETCH_AWARDS_SUCCESS, data);
if (nextPage) {
dispatch('fetchAwards', nextPage);
}
} catch (error) {
Sentry.captureException(error);
}
};
export const toggleAward = async ({ commit, state }, name) => {
const award = state.awards.find((a) => a.name === name && a.user.id === state.currentUserId);
try {
if (award) {
await axios.delete(`${state.path}/${award.id}`);
commit(REMOVE_AWARD, award.id);
showToast(__('Award removed'));
} else {
const { data } = await axios.post(state.path, { name });
commit(ADD_NEW_AWARD, data);
showToast(__('Award added'));
}
} catch (error) {
Sentry.captureException(error);
}
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
Vue.use(Vuex);
const createState = () => ({
awards: [],
awardPath: '',
currentUserId: null,
canAwardEmoji: false,
});
export default () =>
new Vuex.Store({
state: createState(),
actions,
mutations,
});
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const FETCH_AWARDS_SUCCESS = 'FETCH_AWARDS_SUCCESS';
export const ADD_NEW_AWARD = 'ADD_NEW_AWARD';
export const REMOVE_AWARD = 'REMOVE_AWARD';
import {
SET_INITIAL_DATA,
FETCH_AWARDS_SUCCESS,
ADD_NEW_AWARD,
REMOVE_AWARD,
} from './mutation_types';
export default {
[SET_INITIAL_DATA](state, { path, currentUserId, canAwardEmoji }) {
state.path = path;
state.currentUserId = currentUserId;
state.canAwardEmoji = canAwardEmoji;
},
[FETCH_AWARDS_SUCCESS](state, data) {
state.awards.push(...data);
},
[ADD_NEW_AWARD](state, data) {
state.awards.push(data);
},
[REMOVE_AWARD](state, awardId) {
state.awards = state.awards.filter(({ id }) => id !== awardId);
},
};
......@@ -82,6 +82,8 @@ export default {
no-flip
right
lazy
@shown="$emit('shown')"
@hidden="$emit('hidden')"
>
<template #button-content><slot name="button-content"></slot></template>
<gl-search-box-by-type
......
......@@ -46,10 +46,18 @@ export default function initShowIssue() {
new ZenMode(); // eslint-disable-line no-new
if (issueType !== IssuableType.TestCase) {
const awardEmojiEl = document.getElementById('js-vue-awards-block');
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
initIssuableSidebar();
loadAwardsHandler();
if (awardEmojiEl) {
import('~/emoji/awards_app')
.then((m) => m.default(awardEmojiEl))
.catch(() => {});
} else {
loadAwardsHandler();
}
initInviteMemberModal();
initInviteMemberTrigger();
}
......
......@@ -13,13 +13,21 @@ import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode';
export default function initMergeRequestShow() {
const awardEmojiEl = document.getElementById('js-vue-awards-block');
new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar();
initPipelines();
new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash();
initSourcegraph();
loadAwardsHandler();
if (awardEmojiEl) {
import('~/emoji/awards_app')
.then((m) => m.default(awardEmojiEl))
.catch(() => {});
} else {
loadAwardsHandler();
}
initInviteMemberModal();
initInviteMemberTrigger();
initInviteMembersModal();
......
......@@ -44,6 +44,16 @@ export default {
required: false,
default: () => [],
},
selectedClass: {
type: String,
required: false,
default: 'selected',
},
},
data() {
return {
isMenuOpen: false,
};
},
computed: {
groupedDefaultAwards() {
......@@ -68,7 +78,7 @@ export default {
methods: {
getAwardClassBindings(awardList) {
return {
selected: this.hasReactionByCurrentUser(awardList),
[this.selectedClass]: this.hasReactionByCurrentUser(awardList),
disabled: this.currentUserId === NO_USER_ID,
};
},
......@@ -147,6 +157,11 @@ export default {
const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName;
this.$emit('award', parsedName);
if (document.activeElement) document.activeElement.blur();
},
setIsMenuOpen(menuOpen) {
this.isMenuOpen = menuOpen;
},
},
};
......@@ -172,8 +187,10 @@ export default {
<div v-if="canAwardEmoji" class="award-menu-holder">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
toggle-class="add-reaction-button gl-relative!"
:toggle-class="['add-reaction-button gl-relative!', { 'is-active': isMenuOpen }]"
@click="handleAward"
@shown="setIsMenuOpen(true)"
@hidden="setIsMenuOpen(false)"
>
<template #button-content>
<span class="reaction-control-icon reaction-control-icon-neutral">
......
......@@ -362,3 +362,7 @@
}
}
}
.awards .is-active {
box-shadow: inset 0 0 0 1px $blue-200;
}
......@@ -43,6 +43,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:new_pipelines_table, @project, default_enabled: :yaml)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
record_experiment_user(:invite_members_version_b)
......
......@@ -186,6 +186,12 @@ module IssuesHelper
def scoped_labels_available?(parent)
false
end
def award_emoji_issue_api_path(issue)
if Feature.enabled?(:improved_emoji_picker, issue.project, default_enabled: :yaml)
api_v4_projects_issues_award_emoji_path(id: issue.project.id, issue_iid: issue.iid)
end
end
end
IssuesHelper.prepend_if_ee('EE::IssuesHelper')
......@@ -206,6 +206,12 @@ module MergeRequestsHelper
}
end
def award_emoji_merge_request_api_path(merge_request)
if Feature.enabled?(:improved_emoji_picker, merge_request.project, default_enabled: :yaml)
api_v4_projects_merge_requests_award_emoji_path(id: merge_request.project.id, merge_request_iid: merge_request.iid)
end
end
private
def review_requested_merge_requests_count
......
- grouped_emojis = awardable.grouped_awards(with_thumbs: inline)
.awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: toggle_award_url(awardable) } }
- awards_sort(grouped_emojis).each do |emoji, awards|
%button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button",
class: [(award_state_class(awardable, awards, current_user))],
data: { title: award_user_list(awards, current_user) } }
= emoji_icon(emoji)
%span.award-control-text.js-counter
= awards.count
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
- if can?(current_user, :award_emoji, awardable)
.award-menu-holder.js-award-holder
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
'aria-label': _('Add reaction'),
data: { title: _('Add reaction') } }
%span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile')
%span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley')
%span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile')
= yield
- if api_awards_path
.gl-display-flex.gl-flex-wrap
#js-vue-awards-block{ data: { path: api_awards_path, can_award_emoji: can?(current_user, :award_emoji, awardable).to_s } }
= yield
- else
- grouped_emojis = awardable.grouped_awards(with_thumbs: inline)
.awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: toggle_award_url(awardable) } }
- awards_sort(grouped_emojis).each do |emoji, awards|
%button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button",
class: [(award_state_class(awardable, awards, current_user))],
data: { title: award_user_list(awards, current_user) } }
= emoji_icon(emoji)
%span.award-control-text.js-counter
= awards.count
- if can?(current_user, :award_emoji, awardable)
.award-menu-holder.js-award-holder
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
'aria-label': _('Add reaction'),
data: { title: _('Add reaction') } }
%span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile')
%span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley')
%span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile')
= yield
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
- page_description issuable.description_html
- page_card_attributes issuable.card_attributes
- if issuable.relocation_target
......@@ -6,4 +7,4 @@
= render "projects/issues/alert_moved_from_service_desk", issue: issuable
= render 'shared/issue_type/details_header', issuable: issuable
= render 'shared/issue_type/details_content', issuable: issuable
= render 'shared/issue_type/details_content', issuable: issuable, api_awards_path: api_awards_path
......@@ -3,5 +3,5 @@
- breadcrumb_title @issue.to_reference
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
= render 'projects/issuable/show', issuable: @issue
= render 'projects/issuable/show', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue)
= render 'shared/issuable/invite_members_trigger', project: @project
.content-block.content-block-small.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true do
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request) do
.ml-auto.mt-auto.mb-auto
#js-vue-sort-issue-discussions
= render "projects/merge_requests/discussion_filter"
- related_branches_path = related_branches_project_issue_path(@project, issuable)
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
.issue-details.issuable-details
.detail-page-description.content-block
......@@ -24,7 +25,7 @@
#related-branches{ data: { url: related_branches_path } }
-# This element is filled in using JavaScript.
= render 'shared/issue_type/emoji_block', issuable: issuable
= render 'shared/issue_type/emoji_block', issuable: issuable, api_awards_path: api_awards_path
= render 'projects/issues/discussion'
......
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
.content-block.emoji-block.emoji-block-sticky
.row.gl-m-0.gl-justify-content-space-between
.js-noteable-awards
= render 'award_emoji/awards_block', awardable: issuable, inline: true
= render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path
.new-branch-col
= render_if_exists "projects/issues/timeline_toggle", issuable: issuable
#js-vue-sort-issue-discussions
......
......@@ -4624,6 +4624,12 @@ msgstr ""
msgid "Average per day: %{average}"
msgstr ""
msgid "Award added"
msgstr ""
msgid "Award removed"
msgstr ""
msgid "AwardEmoji|No emojis found."
msgstr ""
......
......@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe 'User interacts with awards' do
let(:user) { create(:user) }
before do
stub_feature_flags(improved_emoji_picker: false)
end
describe 'User interacts with awards in an issue', :js do
let(:issue) { create(:issue, project: project)}
let(:project) { create(:project) }
......
......@@ -17,33 +17,28 @@ RSpec.describe 'Merge request > User awards emoji', :js do
end
it 'adds award to merge request' do
first('.js-emoji-btn').click
expect(page).to have_selector('.js-emoji-btn.active')
expect(first('.js-emoji-btn')).to have_content '1'
first('[data-testid="award-button"]').click
expect(page).to have_selector('[data-testid="award-button"].is-active')
expect(first('[data-testid="award-button"]')).to have_content '1'
visit project_merge_request_path(project, merge_request)
expect(first('.js-emoji-btn')).to have_content '1'
expect(first('[data-testid="award-button"]')).to have_content '1'
end
it 'removes award from merge request' do
first('.js-emoji-btn').click
find('.js-emoji-btn.active').click
expect(first('.js-emoji-btn')).to have_content '0'
first('[data-testid="award-button"]').click
find('[data-testid="award-button"].is-active').click
expect(first('[data-testid="award-button"]')).to have_content '0'
visit project_merge_request_path(project, merge_request)
expect(first('.js-emoji-btn')).to have_content '0'
end
it 'has only one menu on the page' do
first('.js-add-award').click
expect(page).to have_selector('.emoji-menu')
expect(page).to have_selector('.emoji-menu', count: 1)
expect(first('[data-testid="award-button"]')).to have_content '0'
end
it 'adds awards to note' do
first('.js-note-emoji').click
first('.emoji-menu .js-emoji-btn').click
page.within('.note-actions') do
first('.note-emoji-button').click
find('gl-emoji[data-name="8ball"]').click
end
wait_for_requests
......
import * as Sentry from '@sentry/browser';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/emoji/awards_app/store/actions';
import axios from '~/lib/utils/axios_utils';
jest.mock('@sentry/browser');
describe('Awards app actions', () => {
describe('setInitialData', () => {
it('commits SET_INITIAL_DATA', async () => {
await testAction(
actions.setInitialData,
{ path: 'https://gitlab.com' },
{},
[{ type: 'SET_INITIAL_DATA', payload: { path: 'https://gitlab.com' } }],
[],
);
});
});
describe('fetchAwards', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('success', () => {
beforeEach(() => {
mock
.onGet('/awards', { params: { per_page: 100, page: '1' } })
.reply(200, ['thumbsup'], { 'x-next-page': '2' });
mock.onGet('/awards', { params: { per_page: 100, page: '2' } }).reply(200, ['thumbsdown']);
});
it('commits FETCH_AWARDS_SUCCESS', async () => {
await testAction(
actions.fetchAwards,
'1',
{ path: '/awards' },
[{ type: 'FETCH_AWARDS_SUCCESS', payload: ['thumbsup'] }],
[{ type: 'fetchAwards', payload: '2' }],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet('/awards').reply(500);
});
it('calls Sentry.captureException', async () => {
await testAction(actions.fetchAwards, null, { path: '/awards' }, [], [], () => {
expect(Sentry.captureException).toHaveBeenCalled();
});
});
});
});
describe('toggleAward', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('adding new award', () => {
describe('success', () => {
beforeEach(() => {
mock.onPost('/awards').reply(200, { id: 1 });
});
it('commits ADD_NEW_AWARD', async () => {
testAction(actions.toggleAward, null, { path: '/awards', awards: [] }, [
{ type: 'ADD_NEW_AWARD', payload: { id: 1 } },
]);
});
});
describe('error', () => {
beforeEach(() => {
mock.onPost('/awards').reply(500);
});
it('calls Sentry.captureException', async () => {
await testAction(
actions.toggleAward,
null,
{ path: '/awards', awards: [] },
[],
[],
() => {
expect(Sentry.captureException).toHaveBeenCalled();
},
);
});
});
});
describe('removing a award', () => {
const mockData = { id: 1, name: 'thumbsup', user: { id: 1 } };
describe('success', () => {
beforeEach(() => {
mock.onDelete('/awards/1').reply(200);
});
it('commits REMOVE_AWARD', async () => {
testAction(
actions.toggleAward,
'thumbsup',
{
path: '/awards',
currentUserId: 1,
awards: [mockData],
},
[{ type: 'REMOVE_AWARD', payload: 1 }],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onDelete('/awards/1').reply(500);
});
it('calls Sentry.captureException', async () => {
await testAction(
actions.toggleAward,
'thumbsup',
{
path: '/awards',
currentUserId: 1,
awards: [mockData],
},
[],
[],
() => {
expect(Sentry.captureException).toHaveBeenCalled();
},
);
});
});
});
});
});
import {
SET_INITIAL_DATA,
FETCH_AWARDS_SUCCESS,
ADD_NEW_AWARD,
REMOVE_AWARD,
} from '~/emoji/awards_app/store/mutation_types';
import mutations from '~/emoji/awards_app/store/mutations';
describe('Awards app mutations', () => {
describe('SET_INITIAL_DATA', () => {
it('sets initial data', () => {
const state = {};
mutations[SET_INITIAL_DATA](state, {
path: 'https://gitlab.com',
currentUserId: 1,
canAwardEmoji: true,
});
expect(state).toEqual({
path: 'https://gitlab.com',
currentUserId: 1,
canAwardEmoji: true,
});
});
});
describe('FETCH_AWARDS_SUCCESS', () => {
it('sets awards', () => {
const state = { awards: [] };
mutations[FETCH_AWARDS_SUCCESS](state, ['thumbsup']);
expect(state.awards).toEqual(['thumbsup']);
});
it('does not overwrite previously set awards', () => {
const state = { awards: ['thumbsup'] };
mutations[FETCH_AWARDS_SUCCESS](state, ['thumbsdown']);
expect(state.awards).toEqual(['thumbsup', 'thumbsdown']);
});
});
describe('ADD_NEW_AWARD', () => {
it('adds new award to array', () => {
const state = { awards: ['thumbsup'] };
mutations[ADD_NEW_AWARD](state, 'thumbsdown');
expect(state.awards).toEqual(['thumbsup', 'thumbsdown']);
});
});
describe('REMOVE_AWARD', () => {
it('removes award from array', () => {
const state = { awards: [{ id: 1 }, { id: 2 }] };
mutations[REMOVE_AWARD](state, 1);
expect(state.awards).toEqual([{ id: 2 }]);
});
});
});
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