Commit d1eae71c authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '263450-fe-set-user-availability-add-busy-checkbox' into 'master'

[FE] Set user availability - Add busy checkbox

See merge request gitlab-org/gitlab!46844
parents 82054e57 95f4f26a
......@@ -566,12 +566,13 @@ const Api = {
});
},
postUserStatus({ emoji, message }) {
postUserStatus({ emoji, message, availability }) {
const url = Api.buildUrl(this.userPostStatusPath);
return axios.put(url, {
emoji,
message,
availability,
});
},
......
import $ from 'jquery';
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
import { highCountTrim } from '~/lib/utils/text_utility';
import Tracking from '~/tracking';
......@@ -34,26 +35,45 @@ function initStatusTriggers() {
const statusModalElement = document.createElement('div');
setStatusModalWrapperEl.appendChild(statusModalElement);
Vue.use(GlToast);
Vue.use(Translate);
// eslint-disable-next-line no-new
new Vue({
el: statusModalElement,
data() {
const { currentEmoji, currentMessage } = setStatusModalWrapperEl.dataset;
const {
currentEmoji,
defaultEmoji,
currentMessage,
currentAvailability,
canSetUserAvailability,
} = setStatusModalWrapperEl.dataset;
return {
currentEmoji,
defaultEmoji,
currentMessage,
currentAvailability,
canSetUserAvailability,
};
},
render(createElement) {
const { currentEmoji, currentMessage } = this;
const {
currentEmoji,
defaultEmoji,
currentMessage,
currentAvailability,
canSetUserAvailability,
} = this;
return createElement(SetStatusModalWrapper, {
props: {
currentEmoji,
defaultEmoji,
currentMessage,
currentAvailability,
canSetUserAvailability,
},
});
},
......
......@@ -2,7 +2,7 @@
/* eslint-disable vue/no-v-html */
import $ from 'jquery';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import { GlModal, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __, s__ } from '~/locale';
import Api from '~/api';
......@@ -11,16 +11,26 @@ import { isUserBusy, isValidAvailibility } from './utils';
import * as Emoji from '~/emoji';
const emojiMenuClass = 'js-modal-status-emoji-menu';
export const AVAILABILITY_STATUS = {
BUSY: 'busy',
NOT_SET: 'not_set',
};
export default {
components: {
GlIcon,
GlModal,
GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
defaultEmoji: {
type: String,
required: false,
default: '',
},
currentEmoji: {
type: String,
required: true,
......@@ -55,8 +65,11 @@ export default {
};
},
computed: {
isCustomEmoji() {
return this.emoji !== this.defaultEmoji;
},
isDirty() {
return this.message.length || this.emoji.length;
return Boolean(this.message.length || this.isCustomEmoji);
},
},
mounted() {
......@@ -80,7 +93,7 @@ export default {
this.emojiTag = Emoji.glEmojiTag(this.emoji);
}
this.noEmoji = this.emoji === '';
this.defaultEmojiTag = Emoji.glEmojiTag('speech_balloon');
this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
this.emojiMenu = new EmojiMenuInModal(
Emoji,
......@@ -89,6 +102,7 @@ export default {
this.setEmoji,
this.$refs.userStatusForm,
);
this.setDefaultEmoji();
})
.catch(() => createFlash(__('Failed to load emoji list.')));
},
......@@ -107,7 +121,7 @@ export default {
},
setDefaultEmoji() {
const { emojiTag } = this;
const hasStatusMessage = this.message;
const hasStatusMessage = Boolean(this.message.length);
if (hasStatusMessage && emojiTag) {
return;
}
......@@ -139,20 +153,26 @@ export default {
this.hideEmojiMenu();
},
removeStatus() {
this.availability = false;
this.clearStatusInputs();
this.setStatus();
},
setStatus() {
const { emoji, message } = this;
const { emoji, message, availability } = this;
Api.postUserStatus({
emoji,
message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
},
onUpdateSuccess() {
this.$toast.show(s__('SetStatusModal|Status updated'), {
type: 'success',
position: 'top-center',
});
this.closeModal();
window.location.reload();
},
......@@ -188,7 +208,7 @@ export default {
name="user[status][emoji]"
/>
<div ref="userStatusForm" class="form-group position-relative m-0">
<div class="input-group">
<div class="input-group gl-mb-5">
<span class="input-group-prepend">
<button
ref="toggleEmojiMenuButton"
......@@ -236,6 +256,22 @@ export default {
</button>
</span>
</div>
<div v-if="canSetUserAvailability" class="form-group">
<div class="gl-display-flex">
<gl-form-checkbox
v-model="availability"
data-testid="user-availability-checkbox"
class="gl-mb-0"
>
<span class="gl-font-weight-bold">{{ s__('SetStatusModal|Busy') }}</span>
</gl-form-checkbox>
</div>
<div class="gl-display-flex">
<span class="gl-text-gray-600 gl-ml-5">
{{ s__('SetStatusModal|"Busy" will be shown next to your name') }}
</span>
</div>
</div>
</div>
</div>
</gl-modal>
......
......@@ -158,6 +158,17 @@ module PageLayoutHelper
end
end
def user_status_properties(user)
default_properties = { current_emoji: '', current_message: '', can_set_user_availability: Feature.enabled?(:set_user_availability_status, user), default_emoji: UserStatus::DEFAULT_EMOJI }
return default_properties unless user&.status
default_properties.merge({
current_emoji: user.status.emoji.to_s,
current_message: user.status.message.to_s,
current_availability: user.status.availability.to_s
})
end
private
def generic_canonical_url
......
......@@ -37,4 +37,10 @@ module ProfilesHelper
def user_status_set_to_busy?(status)
status&.availability == availability_values[:busy]
end
def show_status_emoji?(status)
return false unless status
status.message.present? || status.emoji != UserStatus::DEFAULT_EMOJI
end
end
......@@ -9,8 +9,9 @@
= current_user.to_reference
- if current_user.status
.user-status.d-flex.align-items-center.gl-mt-2.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } }
%span.user-status-emoji.d-flex.align-items-center
= emoji_icon current_user.status.emoji
- if show_status_emoji?(current_user.status)
.user-status-emoji.d-flex.align-items-center
= emoji_icon current_user.status.emoji
%span.user-status-message.str-truncated
= current_user.status.message_html.html_safe
%li.divider
......
- has_impersonation_link = header_link?(:admin_impersonation)
- user_status_data = user_status_properties(current_user)
%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { qa_selector: 'navbar' } }
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
......@@ -103,4 +104,4 @@
#whats-new-app{ data: { storage_key: whats_new_storage_key } }
- if can?(current_user, :update_user_status, current_user)
.js-set-status-modal-wrapper{ data: { current_emoji: current_user.status.present? ? current_user.status.emoji : '', current_message: current_user.status.present? ? current_user.status.message : '' } }
.js-set-status-modal-wrapper{ data: user_status_data }
......@@ -2,6 +2,8 @@
- page_title s_("Profiles|Edit Profile")
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
- availability = availability_values
- custom_emoji = show_status_emoji?(@user.status)
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
= form_errors(@user)
......@@ -48,9 +50,9 @@
- emoji_button = button_tag type: :button,
class: 'js-toggle-emoji-menu emoji-menu-toggle-button gl-button btn has-tooltip',
title: s_("Profiles|Add status emoji") do
- if @user.status
- if custom_emoji
= emoji_icon @user.status.emoji
%span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if @user.status) }
%span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if custom_emoji) }
= sprite_icon('slight-smile', css_class: 'award-control-icon-neutral')
= sprite_icon('smiley', css_class: 'award-control-icon-positive')
= sprite_icon('smile', css_class: 'award-control-icon-super-positive')
......@@ -68,6 +70,10 @@
prepend: emoji_button,
append: reset_message_button,
placeholder: s_("Profiles|What's your status?")
- if Feature.enabled?(:set_user_availability_status, @user)
.checkbox-icon-inline-wrapper
= status_form.check_box :availability, { data: { testid: "user-availability-checkbox" }, label: s_("Profiles|Busy"), wrapper_class: 'gl-mr-0 gl-font-weight-bold' }, availability["busy"], availability["not_set"]
.gl-text-gray-600.gl-ml-5= s_('Profiles|"Busy" will be shown next to your name')
- if Feature.enabled?(:user_time_settings)
%hr
.row.user-time-preferences
......
......@@ -50,7 +50,7 @@
- if @user&.status && user_status_set_to_busy?(@user.status)
%span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)")
- if @user.status
- if show_status_emoji?(@user.status)
.cover-status
= emoji_icon(@user.status.emoji)
= markdown_field(@user.status, :message)
......
---
name: set_user_availability_status
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46844
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/281073
milestone: '13.6'
type: development
group: group::optimize
default_enabled: false
......@@ -20577,6 +20577,9 @@ msgstr ""
msgid "Profiles| You are going to change the username %{currentUsernameBold} to %{newUsernameBold}. Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group. Please update your Git repository remotes as soon as possible."
msgstr ""
msgid "Profiles|\"Busy\" will be shown next to your name"
msgstr ""
msgid "Profiles|%{provider} Active"
msgstr ""
......@@ -20607,6 +20610,9 @@ msgstr ""
msgid "Profiles|Bio"
msgstr ""
msgid "Profiles|Busy"
msgstr ""
msgid "Profiles|Change username"
msgstr ""
......@@ -24734,9 +24740,15 @@ msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr ""
msgid "SetStatusModal|\"Busy\" will be shown next to your name"
msgstr ""
msgid "SetStatusModal|Add status emoji"
msgstr ""
msgid "SetStatusModal|Busy"
msgstr ""
msgid "SetStatusModal|Clear status"
msgstr ""
......@@ -24755,6 +24767,9 @@ msgstr ""
msgid "SetStatusModal|Sorry, we weren't able to set your status. Please try again later."
msgstr ""
msgid "SetStatusModal|Status updated"
msgstr ""
msgid "SetStatusModal|What's your status?"
msgstr ""
......
......@@ -20,6 +20,10 @@ RSpec.describe 'User edit profile' do
wait_for_requests
end
def toggle_busy_status
find('[data-testid="user-availability-checkbox"]').set(true)
end
it 'changes user profile' do
fill_in 'user_skype', with: 'testskype'
fill_in 'user_linkedin', with: 'testlinkedin'
......@@ -180,20 +184,51 @@ RSpec.describe 'User edit profile' do
expect(page).to have_emoji('speech_balloon')
end
end
it 'sets the users status to busy' do
busy_status = find('[data-testid="user-availability-checkbox"]')
expect(busy_status.checked?).to eq(false)
toggle_busy_status
submit_settings
visit profile_path
expect(busy_status.checked?).to eq(true)
end
context 'with set_user_availability_status feature flag disabled' do
before do
stub_feature_flags(set_user_availability_status: false)
visit root_path(user)
end
it 'does not display the availability checkbox' do
expect(page).not_to have_css('[data-testid="user-availability-checkbox"]')
end
end
end
context 'user menu' do
let(:issue) { create(:issue, project: project)}
let(:project) { create(:project) }
def open_user_status_modal
def open_modal(button_text)
find('.header-user-dropdown-toggle').click
page.within ".header-user" do
click_button 'Set status'
click_button button_text
end
end
def open_user_status_modal
open_modal 'Set status'
end
def open_edit_status_modal
open_modal 'Edit status'
end
def set_user_status_in_modal
page.within "#set-user-status-modal" do
click_button 'Set status'
......@@ -246,6 +281,19 @@ RSpec.describe 'User edit profile' do
end
end
it 'sets the users status to busy' do
open_user_status_modal
busy_status = find('[data-testid="user-availability-checkbox"]')
expect(busy_status.checked?).to eq(false)
toggle_busy_status
set_user_status_in_modal
open_edit_status_modal
expect(busy_status.checked?).to eq(true)
end
it 'opens the emoji modal again after closing it' do
open_user_status_modal
select_emoji('biohazard', true)
......@@ -307,11 +355,7 @@ RSpec.describe 'User edit profile' do
expect(page).to have_content user_status.message
end
find('.header-user-dropdown-toggle').click
page.within ".header-user" do
click_button 'Edit status'
end
open_edit_status_modal
find('.js-clear-user-status-button').click
set_user_status_in_modal
......@@ -333,11 +377,7 @@ RSpec.describe 'User edit profile' do
expect(page).to have_content user_status.message
end
find('.header-user-dropdown-toggle').click
page.within ".header-user" do
click_button 'Edit status'
end
open_edit_status_modal
page.within "#set-user-status-modal" do
click_button 'Remove status'
......@@ -357,6 +397,19 @@ RSpec.describe 'User edit profile' do
expect(page).to have_emoji('speech_balloon')
end
end
context 'with set_user_availability_status feature flag disabled' do
before do
stub_feature_flags(set_user_availability_status: false)
visit root_path(user)
end
it 'does not display the availability checkbox' do
open_user_status_modal
expect(page).not_to have_css('[data-testid="user-availability-checkbox"]')
end
end
end
context 'User time preferences', :js do
......
import { shallowMount } from '@vue/test-utils';
import { GlModal, GlFormCheckbox } from '@gitlab/ui';
import { initEmojiMock } from 'helpers/emoji';
import Api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import SetStatusModalWrapper, {
AVAILABILITY_STATUS,
} from '~/set_status_modal/set_status_modal_wrapper.vue';
jest.mock('~/api');
jest.mock('~/flash');
describe('SetStatusModalWrapper', () => {
let wrapper;
let mockEmoji;
const $toast = {
show: jest.fn(),
};
const defaultEmoji = 'speech_balloon';
const defaultMessage = "They're comin' in too fast!";
const defaultProps = {
currentEmoji: defaultEmoji,
currentMessage: defaultMessage,
defaultEmoji,
canSetUserAvailability: true,
};
const createComponent = (props = {}) => {
return shallowMount(SetStatusModalWrapper, {
propsData: {
...defaultProps,
...props,
},
mocks: {
$toast,
},
});
};
const findModal = () => wrapper.find(GlModal);
const findFormField = field => wrapper.find(`[name="user[status][${field}]"]`);
const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button');
const findNoEmojiPlaceholder = () => wrapper.find('.js-no-emoji-placeholder');
const findToggleEmojiButton = () => wrapper.find('.js-toggle-emoji-menu');
const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox);
const initModal = ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => {
const modal = findModal();
// mock internal emoji methods
wrapper.vm.showEmojiMenu = jest.fn();
wrapper.vm.hideEmojiMenu = jest.fn();
if (mockOnUpdateSuccess) wrapper.vm.onUpdateSuccess = jest.fn();
if (mockOnUpdateFailure) wrapper.vm.onUpdateFail = jest.fn();
modal.vm.$emit('shown');
return wrapper.vm.$nextTick();
};
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent();
return initModal();
});
afterEach(() => {
wrapper.destroy();
mockEmoji.restore();
});
describe('with minimum props', () => {
it('sets the hidden status emoji field', () => {
const field = findFormField('emoji');
expect(field.exists()).toBe(true);
expect(field.element.value).toBe(defaultEmoji);
});
it('sets the message field', () => {
const field = findFormField('message');
expect(field.exists()).toBe(true);
expect(field.element.value).toBe(defaultMessage);
});
it('sets the availability field to false', () => {
const field = findAvailabilityCheckbox();
expect(field.exists()).toBe(true);
expect(field.element.checked).toBeUndefined();
});
it('has a clear status button', () => {
expect(findClearStatusButton().isVisible()).toBe(true);
});
it('clicking the toggle emoji button displays the emoji list', () => {
expect(wrapper.vm.showEmojiMenu).not.toHaveBeenCalled();
findToggleEmojiButton().trigger('click');
expect(wrapper.vm.showEmojiMenu).toHaveBeenCalled();
});
});
describe('with no currentMessage set', () => {
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent({ currentMessage: '' });
return initModal();
});
it('does not set the message field', () => {
expect(findFormField('message').element.value).toBe('');
});
it('hides the clear status button', () => {
expect(findClearStatusButton().isVisible()).toBe(false);
});
it('shows the placeholder emoji', () => {
expect(findNoEmojiPlaceholder().isVisible()).toBe(true);
});
});
describe('with no currentEmoji set', () => {
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent({ currentEmoji: '' });
return initModal();
});
it('does not set the hidden status emoji field', () => {
expect(findFormField('emoji').element.value).toBe('');
});
it('hides the placeholder emoji', () => {
expect(findNoEmojiPlaceholder().isVisible()).toBe(false);
});
describe('with no currentMessage set', () => {
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
return initModal();
});
it('shows the placeholder emoji', () => {
expect(findNoEmojiPlaceholder().isVisible()).toBe(true);
});
});
});
describe('update status', () => {
describe('succeeds', () => {
beforeEach(() => {
jest.spyOn(Api, 'postUserStatus').mockResolvedValue();
});
it('clicking "removeStatus" clears the emoji and message fields', async () => {
findModal().vm.$emit('cancel');
await wrapper.vm.$nextTick();
expect(findFormField('message').element.value).toBe('');
expect(findFormField('emoji').element.value).toBe('');
});
it('clicking "setStatus" submits the user status', async () => {
findModal().vm.$emit('ok');
await wrapper.vm.$nextTick();
// set the availability status
findAvailabilityCheckbox().vm.$emit('input', true);
findModal().vm.$emit('ok');
await wrapper.vm.$nextTick();
const commonParams = { emoji: defaultEmoji, message: defaultMessage };
expect(Api.postUserStatus).toHaveBeenCalledTimes(2);
expect(Api.postUserStatus).toHaveBeenNthCalledWith(1, {
availability: AVAILABILITY_STATUS.NOT_SET,
...commonParams,
});
expect(Api.postUserStatus).toHaveBeenNthCalledWith(2, {
availability: AVAILABILITY_STATUS.BUSY,
...commonParams,
});
});
it('calls the "onUpdateSuccess" handler', async () => {
findModal().vm.$emit('ok');
await wrapper.vm.$nextTick();
expect(wrapper.vm.onUpdateSuccess).toHaveBeenCalled();
});
});
describe('success message', () => {
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
jest.spyOn(Api, 'postUserStatus').mockResolvedValue();
return initModal({ mockOnUpdateSuccess: false });
});
it('displays a toast success message', async () => {
findModal().vm.$emit('ok');
await wrapper.vm.$nextTick();
expect($toast.show).toHaveBeenCalledWith('Status updated', {
position: 'top-center',
type: 'success',
});
});
});
describe('with errors', () => {
beforeEach(() => {
jest.spyOn(Api, 'postUserStatus').mockRejectedValue();
});
it('calls the "onUpdateFail" handler', async () => {
findModal().vm.$emit('ok');
await wrapper.vm.$nextTick();
expect(wrapper.vm.onUpdateFail).toHaveBeenCalled();
});
});
describe('error message', () => {
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
jest.spyOn(Api, 'postUserStatus').mockRejectedValue();
return initModal({ mockOnUpdateFailure: false });
});
it('flashes an error message', async () => {
findModal().vm.$emit('ok');
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith(
"Sorry, we weren't able to set your status. Please try again later.",
);
});
});
});
describe('with canSetUserAvailability=false', () => {
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent({ canSetUserAvailability: false });
return initModal();
});
it('hides the set availability checkbox', () => {
expect(findAvailabilityCheckbox().exists()).toBe(false);
});
});
});
......@@ -221,4 +221,42 @@ RSpec.describe PageLayoutHelper do
end
end
end
describe '#user_status_properties' do
using RSpec::Parameterized::TableSyntax
let(:user) { build(:user) }
availability_types = Types::AvailabilityEnum.enum
where(:message, :emoji, :availability) do
"Some message" | UserStatus::DEFAULT_EMOJI | availability_types[:busy]
"Some message" | UserStatus::DEFAULT_EMOJI | availability_types[:not_set]
"Some message" | "basketball" | availability_types[:busy]
"Some message" | "basketball" | availability_types[:not_set]
"Some message" | "" | availability_types[:busy]
"Some message" | "" | availability_types[:not_set]
"" | UserStatus::DEFAULT_EMOJI | availability_types[:busy]
"" | UserStatus::DEFAULT_EMOJI | availability_types[:not_set]
"" | "basketball" | availability_types[:busy]
"" | "basketball" | availability_types[:not_set]
"" | "" | availability_types[:busy]
"" | "" | availability_types[:not_set]
end
with_them do
it "sets the default user status fields" do
user.status = UserStatus.new(message: message, emoji: emoji, availability: availability)
result = {
can_set_user_availability: true,
current_availability: availability,
current_emoji: emoji,
current_message: message,
default_emoji: UserStatus::DEFAULT_EMOJI
}
expect(helper.user_status_properties(user)).to eq(result)
end
end
end
end
......@@ -95,6 +95,23 @@ RSpec.describe ProfilesHelper do
end
end
describe "#show_status_emoji?" do
using RSpec::Parameterized::TableSyntax
where(:message, :emoji, :result) do
"Some message" | UserStatus::DEFAULT_EMOJI | true
"Some message" | "" | true
"" | "basketball" | true
"" | "basketball" | true
"" | UserStatus::DEFAULT_EMOJI | false
"" | UserStatus::DEFAULT_EMOJI | false
end
with_them do
it { expect(helper.show_status_emoji?(OpenStruct.new(message: message, emoji: emoji))).to eq(result) }
end
end
def stub_cas_omniauth_provider
provider = OpenStruct.new(
'name' => 'cas3',
......
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