Commit 7a3d74af authored by Mike Greiling's avatar Mike Greiling 🏄🏻

Merge branch 'winh-restyle-user-status' into 'master'

Restyle status message input on profile settings

Closes #49645

See merge request gitlab-org/gitlab-ce!20903
parents 9c0f5271 1c10e014
...@@ -33,19 +33,24 @@ const categoryLabelMap = { ...@@ -33,19 +33,24 @@ const categoryLabelMap = {
const IS_VISIBLE = 'is-visible'; const IS_VISIBLE = 'is-visible';
const IS_RENDERED = 'is-rendered'; const IS_RENDERED = 'is-rendered';
class AwardsHandler { export class AwardsHandler {
constructor(emoji) { constructor(emoji) {
this.emoji = emoji; this.emoji = emoji;
this.eventListeners = []; this.eventListeners = [];
this.toggleButtonSelector = '.js-add-award';
this.menuClass = 'js-award-emoji-menu';
}
bindEvents() {
// If the user shows intent let's pre-build the menu // If the user shows intent let's pre-build the menu
this.registerEventListener( this.registerEventListener(
'one', 'one',
$(document), $(document),
'mouseenter focus', 'mouseenter focus',
'.js-add-award', this.toggleButtonSelector,
'mouseenter focus', 'mouseenter focus',
() => { () => {
const $menu = $('.emoji-menu'); const $menu = $(`.${this.menuClass}`);
if ($menu.length === 0) { if ($menu.length === 0) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.createEmojiMenu(); this.createEmojiMenu();
...@@ -53,7 +58,7 @@ class AwardsHandler { ...@@ -53,7 +58,7 @@ class AwardsHandler {
} }
}, },
); );
this.registerEventListener('on', $(document), 'click', '.js-add-award', e => { this.registerEventListener('on', $(document), 'click', this.toggleButtonSelector, e => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.showEmojiMenu($(e.currentTarget)); this.showEmojiMenu($(e.currentTarget));
...@@ -61,15 +66,17 @@ class AwardsHandler { ...@@ -61,15 +66,17 @@ class AwardsHandler {
this.registerEventListener('on', $('html'), 'click', e => { this.registerEventListener('on', $('html'), 'click', e => {
const $target = $(e.target); const $target = $(e.target);
if (!$target.closest('.emoji-menu').length) { if (!$target.closest(`.${this.menuClass}`).length) {
$('.js-awards-block.current').removeClass('current'); $('.js-awards-block.current').removeClass('current');
if ($('.emoji-menu').is(':visible')) { if ($(`.${this.menuClass}`).is(':visible')) {
$('.js-add-award.is-active').removeClass('is-active'); $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active');
this.hideMenuElement($('.emoji-menu')); this.hideMenuElement($(`.${this.menuClass}`));
} }
} }
}); });
this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', e => {
const emojiButtonSelector = `.js-awards-block .js-emoji-btn, .${this.menuClass} .js-emoji-btn`;
this.registerEventListener('on', $(document), 'click', emojiButtonSelector, e => {
e.preventDefault(); e.preventDefault();
const $target = $(e.currentTarget); const $target = $(e.currentTarget);
const $glEmojiElement = $target.find('gl-emoji'); const $glEmojiElement = $target.find('gl-emoji');
...@@ -101,7 +108,7 @@ class AwardsHandler { ...@@ -101,7 +108,7 @@ class AwardsHandler {
$addBtn.closest('.js-awards-block').addClass('current'); $addBtn.closest('.js-awards-block').addClass('current');
} }
const $menu = $('.emoji-menu'); const $menu = $(`.${this.menuClass}`);
const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent(); const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent();
const $userAuthored = this.isUserAuthored($addBtn); const $userAuthored = this.isUserAuthored($addBtn);
if ($menu.length) { if ($menu.length) {
...@@ -118,7 +125,7 @@ class AwardsHandler { ...@@ -118,7 +125,7 @@ class AwardsHandler {
} else { } else {
$addBtn.addClass('is-loading is-active'); $addBtn.addClass('is-loading is-active');
this.createEmojiMenu(() => { this.createEmojiMenu(() => {
const $createdMenu = $('.emoji-menu'); const $createdMenu = $(`.${this.menuClass}`);
$addBtn.removeClass('is-loading'); $addBtn.removeClass('is-loading');
this.positionMenu($createdMenu, $addBtn); this.positionMenu($createdMenu, $addBtn);
return setTimeout(() => { return setTimeout(() => {
...@@ -156,7 +163,7 @@ class AwardsHandler { ...@@ -156,7 +163,7 @@ class AwardsHandler {
} }
const emojiMenuMarkup = ` const emojiMenuMarkup = `
<div class="emoji-menu"> <div class="emoji-menu ${this.menuClass}">
<input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" /> <input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" />
<div class="emoji-menu-content"> <div class="emoji-menu-content">
...@@ -185,7 +192,7 @@ class AwardsHandler { ...@@ -185,7 +192,7 @@ class AwardsHandler {
// Avoid the jank and render the remaining categories separately // Avoid the jank and render the remaining categories separately
// This will take more time, but makes UI more responsive // This will take more time, but makes UI more responsive
const menu = document.querySelector('.emoji-menu'); const menu = document.querySelector(`.${this.menuClass}`);
const emojiContentElement = menu.querySelector('.emoji-menu-content'); const emojiContentElement = menu.querySelector('.emoji-menu-content');
const remainingCategories = Object.keys(categoryMap).slice(1); const remainingCategories = Object.keys(categoryMap).slice(1);
const allCategoriesAddedPromise = remainingCategories.reduce( const allCategoriesAddedPromise = remainingCategories.reduce(
...@@ -270,9 +277,9 @@ class AwardsHandler { ...@@ -270,9 +277,9 @@ class AwardsHandler {
if (isInVueNoteablePage() && !isMainAwardsBlock) { if (isInVueNoteablePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', ''); const id = votesBlock.attr('id').replace('note_', '');
this.hideMenuElement($('.emoji-menu')); this.hideMenuElement($(`.${this.menuClass}`));
$('.js-add-award.is-active').removeClass('is-active'); $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active');
const toggleAwardEvent = new CustomEvent('toggleAward', { const toggleAwardEvent = new CustomEvent('toggleAward', {
detail: { detail: {
awardName: emoji, awardName: emoji,
...@@ -291,9 +298,9 @@ class AwardsHandler { ...@@ -291,9 +298,9 @@ class AwardsHandler {
return typeof callback === 'function' ? callback() : undefined; return typeof callback === 'function' ? callback() : undefined;
}); });
this.hideMenuElement($('.emoji-menu')); this.hideMenuElement($(`.${this.menuClass}`));
return $('.js-add-award.is-active').removeClass('is-active'); return $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active');
} }
addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) { addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
...@@ -321,7 +328,7 @@ class AwardsHandler { ...@@ -321,7 +328,7 @@ class AwardsHandler {
getVotesBlock() { getVotesBlock() {
if (isInVueNoteablePage()) { if (isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); const $el = $(`${this.toggleButtonSelector}.is-active`).closest('.note.timeline-entry');
if ($el.length) { if ($el.length) {
return $el; return $el;
...@@ -458,7 +465,7 @@ class AwardsHandler { ...@@ -458,7 +465,7 @@ class AwardsHandler {
} }
createEmoji(votesBlock, emoji) { createEmoji(votesBlock, emoji) {
if ($('.emoji-menu').length) { if ($(`.${this.menuClass}`).length) {
this.createAwardButtonForVotesBlock(votesBlock, emoji); this.createAwardButtonForVotesBlock(votesBlock, emoji);
} }
this.createEmojiMenu(() => { this.createEmojiMenu(() => {
...@@ -538,7 +545,7 @@ class AwardsHandler { ...@@ -538,7 +545,7 @@ class AwardsHandler {
this.searchEmojis(term); this.searchEmojis(term);
}); });
const $menu = $('.emoji-menu'); const $menu = $(`.${this.menuClass}`);
this.registerEventListener('on', $menu, transitionEndEventString, e => { this.registerEventListener('on', $menu, transitionEndEventString, e => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
// Clear the search // Clear the search
...@@ -608,7 +615,7 @@ class AwardsHandler { ...@@ -608,7 +615,7 @@ class AwardsHandler {
this.eventListeners.forEach(entry => { this.eventListeners.forEach(entry => {
entry.element.off.call(entry.element, ...entry.args); entry.element.off.call(entry.element, ...entry.args);
}); });
$('.emoji-menu').remove(); $(`.${this.menuClass}`).remove();
} }
} }
...@@ -616,7 +623,11 @@ let awardsHandlerPromise = null; ...@@ -616,7 +623,11 @@ let awardsHandlerPromise = null;
export default function loadAwardsHandler(reload = false) { export default function loadAwardsHandler(reload = false) {
if (!awardsHandlerPromise || reload) { if (!awardsHandlerPromise || reload) {
awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then( awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(
Emoji => new AwardsHandler(Emoji), Emoji => {
const awardsHandler = new AwardsHandler(Emoji);
awardsHandler.bindEvents();
return awardsHandler;
},
); );
} }
return awardsHandlerPromise; return awardsHandlerPromise;
......
import { AwardsHandler } from '~/awards_handler';
class EmojiMenu extends AwardsHandler {
constructor(emoji, toggleButtonSelector, menuClass, selectEmojiCallback) {
super(emoji);
this.selectEmojiCallback = selectEmojiCallback;
this.toggleButtonSelector = toggleButtonSelector;
this.menuClass = menuClass;
}
postEmoji($emojiButton, awardUrl, selectedEmoji, callback) {
this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji));
callback();
}
}
export default EmojiMenu;
import $ from 'jquery';
import createFlash from '~/flash';
import GfmAutoComplete from '~/gfm_auto_complete';
import EmojiMenu from './emoji_menu';
document.addEventListener('DOMContentLoaded', () => {
const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu';
const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector);
const statusEmojiField = document.getElementById('js-status-emoji-field');
const statusMessageField = document.getElementById('js-status-message-field');
const findNoEmojiPlaceholder = () => document.getElementById('js-no-emoji-placeholder');
const removeStatusEmoji = () => {
const statusEmoji = toggleEmojiMenuButton.querySelector('gl-emoji');
if (statusEmoji) {
statusEmoji.remove();
}
};
const selectEmojiCallback = (emoji, emojiTag) => {
statusEmojiField.value = emoji;
findNoEmojiPlaceholder().classList.add('hidden');
removeStatusEmoji();
toggleEmojiMenuButton.innerHTML += emojiTag;
};
const clearEmojiButton = document.getElementById('js-clear-user-status-button');
clearEmojiButton.addEventListener('click', () => {
statusEmojiField.value = '';
statusMessageField.value = '';
removeStatusEmoji();
findNoEmojiPlaceholder().classList.remove('hidden');
});
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(statusMessageField), { emojis: true });
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
const emojiMenu = new EmojiMenu(
Emoji,
toggleEmojiMenuButtonSelector,
'js-status-emoji-menu',
selectEmojiCallback,
);
emojiMenu.bindEvents();
})
.catch(() => createFlash('Failed to load emoji list!'));
});
...@@ -339,3 +339,13 @@ input[type=color].form-control { ...@@ -339,3 +339,13 @@ input[type=color].form-control {
vertical-align: unset; vertical-align: unset;
} }
} }
// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet
.input-group-btn:first-child {
@extend .input-group-prepend;
}
// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet
.input-group-btn:last-child {
@extend .input-group-append;
}
...@@ -546,6 +546,7 @@ ul.notes { ...@@ -546,6 +546,7 @@ ul.notes {
svg { svg {
@include btn-svg; @include btn-svg;
margin: 0;
} }
.award-control-icon-positive, .award-control-icon-positive,
......
...@@ -418,3 +418,23 @@ table.u2f-registrations { ...@@ -418,3 +418,23 @@ table.u2f-registrations {
} }
} }
} }
.edit-user {
.clear-user-status {
svg {
fill: $gl-text-color-secondary;
}
}
.emoji-menu-toggle-button {
@extend .note-action-button;
.no-emoji-placeholder {
position: relative;
svg {
fill: $gl-text-color-secondary;
}
}
}
}
...@@ -9,8 +9,4 @@ module ProfilesHelper ...@@ -9,8 +9,4 @@ module ProfilesHelper
end end
end end
end end
def show_user_status_field?
Feature.enabled?(:user_status_form) || cookies[:feature_user_status_form] == 'true'
end
end end
...@@ -31,17 +31,37 @@ ...@@ -31,17 +31,37 @@
%hr %hr
= link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted' = link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted'
- if show_user_status_field? %hr
%hr .row
.row .col-lg-4.profile-settings-sidebar
.col-lg-4.profile-settings-sidebar %h4.prepend-top-0= s_("User|Current status")
%h4.prepend-top-0= s_("User|Current Status") %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
%p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface. The message can contain emoji codes, too.") .col-lg-8
.col-lg-8 = f.fields_for :status, @user.status do |status_form|
.row - emoji_button = button_tag type: :button,
= f.fields_for :status, @user.status do |status_form| class: 'js-toggle-emoji-menu emoji-menu-toggle-button btn has-tooltip',
= status_form.text_field :emoji title: s_("Profiles|Add status emoji") do
= status_form.text_field :message, maxlength: 100 - if @user.status
= emoji_icon @user.status.emoji
%span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if @user.status) }
= sprite_icon('emoji_slightly_smiling_face', css_class: 'award-control-icon-neutral')
= sprite_icon('emoji_smiley', css_class: 'award-control-icon-positive')
= sprite_icon('emoji_smile', css_class: 'award-control-icon-super-positive')
- reset_message_button = button_tag type: :button,
id: 'js-clear-user-status-button',
class: 'clear-user-status btn has-tooltip',
title: s_("Profiles|Clear status") do
= sprite_icon("close")
= status_form.hidden_field :emoji, id: 'js-status-emoji-field'
= status_form.text_field :message,
id: 'js-status-message-field',
class: 'form-control input-lg',
label: s_("Profiles|Your status"),
prepend: emoji_button,
append: reset_message_button,
placeholder: s_("Profiles|What's your status?")
%hr %hr
.row .row
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
---
title: Restyle status message input on profile settings
merge_request: 20903
author:
type: changed
...@@ -4118,9 +4118,15 @@ msgstr "" ...@@ -4118,9 +4118,15 @@ msgstr ""
msgid "Profiles|Add key" msgid "Profiles|Add key"
msgstr "" msgstr ""
msgid "Profiles|Add status emoji"
msgstr ""
msgid "Profiles|Change username" msgid "Profiles|Change username"
msgstr "" msgstr ""
msgid "Profiles|Clear status"
msgstr ""
msgid "Profiles|Current path: %{path}" msgid "Profiles|Current path: %{path}"
msgstr "" msgstr ""
...@@ -4148,7 +4154,7 @@ msgstr "" ...@@ -4148,7 +4154,7 @@ msgstr ""
msgid "Profiles|This doesn't look like a public SSH key, are you sure you want to add it?" msgid "Profiles|This doesn't look like a public SSH key, are you sure you want to add it?"
msgstr "" msgstr ""
msgid "Profiles|This emoji and message will appear on your profile and throughout the interface. The message can contain emoji codes, too." msgid "Profiles|This emoji and message will appear on your profile and throughout the interface."
msgstr "" msgstr ""
msgid "Profiles|Type your %{confirmationValue} to confirm:" msgid "Profiles|Type your %{confirmationValue} to confirm:"
...@@ -4166,6 +4172,9 @@ msgstr "" ...@@ -4166,6 +4172,9 @@ msgstr ""
msgid "Profiles|Username successfully changed" msgid "Profiles|Username successfully changed"
msgstr "" msgstr ""
msgid "Profiles|What's your status?"
msgstr ""
msgid "Profiles|You don't have access to delete this user." msgid "Profiles|You don't have access to delete this user."
msgstr "" msgstr ""
...@@ -4175,6 +4184,9 @@ msgstr "" ...@@ -4175,6 +4184,9 @@ msgstr ""
msgid "Profiles|Your account is currently an owner in these groups:" msgid "Profiles|Your account is currently an owner in these groups:"
msgstr "" msgstr ""
msgid "Profiles|Your status"
msgstr ""
msgid "Profiles|e.g. My MacBook key" msgid "Profiles|e.g. My MacBook key"
msgstr "" msgstr ""
...@@ -5763,7 +5775,7 @@ msgstr "" ...@@ -5763,7 +5775,7 @@ msgstr ""
msgid "Users" msgid "Users"
msgstr "" msgstr ""
msgid "User|Current Status" msgid "User|Current status"
msgstr "" msgstr ""
msgid "Variables" msgid "Variables"
......
...@@ -8,6 +8,10 @@ describe 'User edit profile' do ...@@ -8,6 +8,10 @@ describe 'User edit profile' do
visit(profile_path) visit(profile_path)
end end
def submit_settings
click_button 'Update profile settings'
end
it 'changes user profile' do it 'changes user profile' do
fill_in 'user_skype', with: 'testskype' fill_in 'user_skype', with: 'testskype'
fill_in 'user_linkedin', with: 'testlinkedin' fill_in 'user_linkedin', with: 'testlinkedin'
...@@ -16,7 +20,7 @@ describe 'User edit profile' do ...@@ -16,7 +20,7 @@ describe 'User edit profile' do
fill_in 'user_location', with: 'Ukraine' fill_in 'user_location', with: 'Ukraine'
fill_in 'user_bio', with: 'I <3 GitLab' fill_in 'user_bio', with: 'I <3 GitLab'
fill_in 'user_organization', with: 'GitLab' fill_in 'user_organization', with: 'GitLab'
click_button 'Update profile settings' submit_settings
expect(user.reload).to have_attributes( expect(user.reload).to have_attributes(
skype: 'testskype', skype: 'testskype',
...@@ -34,7 +38,7 @@ describe 'User edit profile' do ...@@ -34,7 +38,7 @@ describe 'User edit profile' do
context 'user avatar' do context 'user avatar' do
before do before do
attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif')) attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif'))
click_button 'Update profile settings' submit_settings
end end
it 'changes user avatar' do it 'changes user avatar' do
...@@ -56,30 +60,75 @@ describe 'User edit profile' do ...@@ -56,30 +60,75 @@ describe 'User edit profile' do
end end
end end
context 'user status' do context 'user status', :js do
it 'hides user status when the feature is disabled' do def select_emoji(emoji_name)
stub_feature_flags(user_status_form: false) toggle_button = find('.js-toggle-emoji-menu')
toggle_button.click
emoji_button = find(%Q{.js-status-emoji-menu .js-emoji-btn gl-emoji[data-name="#{emoji_name}"]})
emoji_button.click
end
it 'shows the user status form' do
visit(profile_path) visit(profile_path)
expect(page).not_to have_content('Current Status') expect(page).to have_content('Current status')
end end
it 'shows the status form when the feature is enabled' do it 'adds emoji to user status' do
stub_feature_flags(user_status_form: true) emoji = 'biohazard'
visit(profile_path)
select_emoji(emoji)
submit_settings
visit user_path(user)
within('.cover-status') do
expect(page).to have_emoji(emoji)
end
end
it 'adds message to user status' do
message = 'I have something to say'
visit(profile_path) visit(profile_path)
fill_in 'js-status-message-field', with: message
submit_settings
visit user_path(user)
within('.cover-status') do
expect(page).to have_emoji('speech_balloon')
expect(page).to have_content message
end
end
expect(page).to have_content('Current Status') it 'adds message and emoji to user status' do
emoji = 'tanabata_tree'
message = 'Playing outside'
visit(profile_path)
select_emoji(emoji)
fill_in 'js-status-message-field', with: message
submit_settings
visit user_path(user)
within('.cover-status') do
expect(page).to have_emoji(emoji)
expect(page).to have_content message
end
end end
it 'shows the status form when the feature is enabled by setting a cookie', :js do it 'clears the user status' do
stub_feature_flags(user_status_form: false) user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread')
set_cookie('feature_user_status_form', 'true')
visit user_path(user)
within('.cover-status') do
expect(page).to have_emoji(user_status.emoji)
expect(page).to have_content user_status.message
end
visit(profile_path) visit(profile_path)
click_button 'js-clear-user-status-button'
submit_settings
expect(page).to have_content('Current Status') visit user_path(user)
expect(page).not_to have_selector '.cover-status'
end end
end end
end end
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import EmojiMenu from '~/pages/profiles/show/emoji_menu';
import { TEST_HOST } from 'spec/test_constants';
describe('EmojiMenu', () => {
const dummyEmojiTag = '<dummy></tag>';
const dummyToggleButtonSelector = '.toggle-button-selector';
const dummyMenuClass = 'dummy-menu-class';
let emojiMenu;
let dummySelectEmojiCallback;
let dummyEmojiList;
beforeEach(() => {
dummySelectEmojiCallback = jasmine.createSpy('dummySelectEmojiCallback');
dummyEmojiList = {
glEmojiTag() {
return dummyEmojiTag;
},
normalizeEmojiName(emoji) {
return emoji;
},
isEmojiNameValid() {
return true;
},
getEmojiCategoryMap() {
return { dummyCategory: [] };
},
};
emojiMenu = new EmojiMenu(
dummyEmojiList,
dummyToggleButtonSelector,
dummyMenuClass,
dummySelectEmojiCallback,
);
});
afterEach(() => {
emojiMenu.destroy();
});
describe('addAward', () => {
const dummyAwardUrl = `${TEST_HOST}/award/url`;
const dummyEmoji = 'tropical_fish';
const dummyVotesBlock = () => $('<div />');
it('calls selectEmojiCallback', done => {
expect(dummySelectEmojiCallback).not.toHaveBeenCalled();
emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => {
expect(dummySelectEmojiCallback).toHaveBeenCalledWith(dummyEmoji, dummyEmojiTag);
done();