Commit d21f12c0 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'ph/324666/awardsBlockRewriteToVue' into 'master'

Refactors the awards block to use Vue

See merge request gitlab-org/gitlab!58277
parents 0028339c 006e0b67
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 { ...@@ -82,6 +82,8 @@ export default {
no-flip no-flip
right right
lazy lazy
@shown="$emit('shown')"
@hidden="$emit('hidden')"
> >
<template #button-content><slot name="button-content"></slot></template> <template #button-content><slot name="button-content"></slot></template>
<gl-search-box-by-type <gl-search-box-by-type
......
...@@ -46,10 +46,18 @@ export default function initShowIssue() { ...@@ -46,10 +46,18 @@ export default function initShowIssue() {
new ZenMode(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new
if (issueType !== IssuableType.TestCase) { if (issueType !== IssuableType.TestCase) {
const awardEmojiEl = document.getElementById('js-vue-awards-block');
new Issue(); // eslint-disable-line no-new new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new
initIssuableSidebar(); initIssuableSidebar();
if (awardEmojiEl) {
import('~/emoji/awards_app')
.then((m) => m.default(awardEmojiEl))
.catch(() => {});
} else {
loadAwardsHandler(); loadAwardsHandler();
}
initInviteMemberModal(); initInviteMemberModal();
initInviteMemberTrigger(); initInviteMemberTrigger();
} }
......
...@@ -13,13 +13,21 @@ import initSourcegraph from '~/sourcegraph'; ...@@ -13,13 +13,21 @@ import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode'; import ZenMode from '~/zen_mode';
export default function initMergeRequestShow() { export default function initMergeRequestShow() {
const awardEmojiEl = document.getElementById('js-vue-awards-block');
new ZenMode(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar(); initIssuableSidebar();
initPipelines(); initPipelines();
new ShortcutsIssuable(true); // eslint-disable-line no-new new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash(); handleLocationHash();
initSourcegraph(); initSourcegraph();
if (awardEmojiEl) {
import('~/emoji/awards_app')
.then((m) => m.default(awardEmojiEl))
.catch(() => {});
} else {
loadAwardsHandler(); loadAwardsHandler();
}
initInviteMemberModal(); initInviteMemberModal();
initInviteMemberTrigger(); initInviteMemberTrigger();
initInviteMembersModal(); initInviteMembersModal();
......
...@@ -44,6 +44,16 @@ export default { ...@@ -44,6 +44,16 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
selectedClass: {
type: String,
required: false,
default: 'selected',
},
},
data() {
return {
isMenuOpen: false,
};
}, },
computed: { computed: {
groupedDefaultAwards() { groupedDefaultAwards() {
...@@ -68,7 +78,7 @@ export default { ...@@ -68,7 +78,7 @@ export default {
methods: { methods: {
getAwardClassBindings(awardList) { getAwardClassBindings(awardList) {
return { return {
selected: this.hasReactionByCurrentUser(awardList), [this.selectedClass]: this.hasReactionByCurrentUser(awardList),
disabled: this.currentUserId === NO_USER_ID, disabled: this.currentUserId === NO_USER_ID,
}; };
}, },
...@@ -147,6 +157,11 @@ export default { ...@@ -147,6 +157,11 @@ export default {
const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName; const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName;
this.$emit('award', parsedName); this.$emit('award', parsedName);
if (document.activeElement) document.activeElement.blur();
},
setIsMenuOpen(menuOpen) {
this.isMenuOpen = menuOpen;
}, },
}, },
}; };
...@@ -172,8 +187,10 @@ export default { ...@@ -172,8 +187,10 @@ export default {
<div v-if="canAwardEmoji" class="award-menu-holder"> <div v-if="canAwardEmoji" class="award-menu-holder">
<emoji-picker <emoji-picker
v-if="glFeatures.improvedEmojiPicker" v-if="glFeatures.improvedEmojiPicker"
toggle-class="add-reaction-button gl-relative!" :toggle-class="['add-reaction-button gl-relative!', { 'is-active': isMenuOpen }]"
@click="handleAward" @click="handleAward"
@shown="setIsMenuOpen(true)"
@hidden="setIsMenuOpen(false)"
> >
<template #button-content> <template #button-content>
<span class="reaction-control-icon reaction-control-icon-neutral"> <span class="reaction-control-icon reaction-control-icon-neutral">
......
...@@ -362,3 +362,7 @@ ...@@ -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 ...@@ -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(:new_pipelines_table, @project, default_enabled: :yaml)
push_frontend_feature_flag(:confidential_notes, @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(: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) record_experiment_user(:invite_members_version_b)
......
...@@ -186,6 +186,12 @@ module IssuesHelper ...@@ -186,6 +186,12 @@ module IssuesHelper
def scoped_labels_available?(parent) def scoped_labels_available?(parent)
false false
end 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 end
IssuesHelper.prepend_if_ee('EE::IssuesHelper') IssuesHelper.prepend_if_ee('EE::IssuesHelper')
...@@ -206,6 +206,12 @@ module MergeRequestsHelper ...@@ -206,6 +206,12 @@ module MergeRequestsHelper
} }
end 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 private
def review_requested_merge_requests_count def review_requested_merge_requests_count
......
- grouped_emojis = awardable.grouped_awards(with_thumbs: inline) - api_awards_path = local_assigns.fetch(:api_awards_path, nil)
.awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: toggle_award_url(awardable) } }
- 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| - awards_sort(grouped_emojis).each do |emoji, awards|
%button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button", %button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button",
class: [(award_state_class(awardable, awards, current_user))], class: [(award_state_class(awardable, awards, current_user))],
......
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
- page_description issuable.description_html - page_description issuable.description_html
- page_card_attributes issuable.card_attributes - page_card_attributes issuable.card_attributes
- if issuable.relocation_target - if issuable.relocation_target
...@@ -6,4 +7,4 @@ ...@@ -6,4 +7,4 @@
= render "projects/issues/alert_moved_from_service_desk", issue: issuable = render "projects/issues/alert_moved_from_service_desk", issue: issuable
= render 'shared/issue_type/details_header', issuable: 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 @@ ...@@ -3,5 +3,5 @@
- breadcrumb_title @issue.to_reference - breadcrumb_title @issue.to_reference
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues") - 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 = render 'shared/issuable/invite_members_trigger', project: @project
.content-block.content-block-small.emoji-list-container.js-noteable-awards .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 .ml-auto.mt-auto.mb-auto
#js-vue-sort-issue-discussions #js-vue-sort-issue-discussions
= render "projects/merge_requests/discussion_filter" = render "projects/merge_requests/discussion_filter"
- related_branches_path = related_branches_project_issue_path(@project, issuable) - related_branches_path = related_branches_project_issue_path(@project, issuable)
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
.issue-details.issuable-details .issue-details.issuable-details
.detail-page-description.content-block .detail-page-description.content-block
...@@ -24,7 +25,7 @@ ...@@ -24,7 +25,7 @@
#related-branches{ data: { url: related_branches_path } } #related-branches{ data: { url: related_branches_path } }
-# This element is filled in using JavaScript. -# 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' = render 'projects/issues/discussion'
......
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
.content-block.emoji-block.emoji-block-sticky .content-block.emoji-block.emoji-block-sticky
.row.gl-m-0.gl-justify-content-space-between .row.gl-m-0.gl-justify-content-space-between
.js-noteable-awards .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 .new-branch-col
= render_if_exists "projects/issues/timeline_toggle", issuable: issuable = render_if_exists "projects/issues/timeline_toggle", issuable: issuable
#js-vue-sort-issue-discussions #js-vue-sort-issue-discussions
......
...@@ -4624,6 +4624,12 @@ msgstr "" ...@@ -4624,6 +4624,12 @@ msgstr ""
msgid "Average per day: %{average}" msgid "Average per day: %{average}"
msgstr "" msgstr ""
msgid "Award added"
msgstr ""
msgid "Award removed"
msgstr ""
msgid "AwardEmoji|No emojis found." msgid "AwardEmoji|No emojis found."
msgstr "" msgstr ""
......
...@@ -5,6 +5,10 @@ require 'spec_helper' ...@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe 'User interacts with awards' do RSpec.describe 'User interacts with awards' do
let(:user) { create(:user) } let(:user) { create(:user) }
before do
stub_feature_flags(improved_emoji_picker: false)
end
describe 'User interacts with awards in an issue', :js do describe 'User interacts with awards in an issue', :js do
let(:issue) { create(:issue, project: project)} let(:issue) { create(:issue, project: project)}
let(:project) { create(:project) } let(:project) { create(:project) }
......
...@@ -17,33 +17,28 @@ RSpec.describe 'Merge request > User awards emoji', :js do ...@@ -17,33 +17,28 @@ RSpec.describe 'Merge request > User awards emoji', :js do
end end
it 'adds award to merge request' do it 'adds award to merge request' do
first('.js-emoji-btn').click first('[data-testid="award-button"]').click
expect(page).to have_selector('.js-emoji-btn.active') expect(page).to have_selector('[data-testid="award-button"].is-active')
expect(first('.js-emoji-btn')).to have_content '1' expect(first('[data-testid="award-button"]')).to have_content '1'
visit project_merge_request_path(project, merge_request) 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 end
it 'removes award from merge request' do it 'removes award from merge request' do
first('.js-emoji-btn').click first('[data-testid="award-button"]').click
find('.js-emoji-btn.active').click find('[data-testid="award-button"].is-active').click
expect(first('.js-emoji-btn')).to have_content '0' expect(first('[data-testid="award-button"]')).to have_content '0'
visit project_merge_request_path(project, merge_request) visit project_merge_request_path(project, merge_request)
expect(first('.js-emoji-btn')).to have_content '0' expect(first('[data-testid="award-button"]')).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)
end end
it 'adds awards to note' do it 'adds awards to note' do
first('.js-note-emoji').click page.within('.note-actions') do
first('.emoji-menu .js-emoji-btn').click first('.note-emoji-button').click
find('gl-emoji[data-name="8ball"]').click
end
wait_for_requests 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