Commit 95f4f26a authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Bob Van Landuyt

Set user availability from profile settings

Allows users to set their user availability
status from the profile settings page
and set status modal

Set busy status from status modal
parent 0a719f47
......@@ -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,7 +9,8 @@
= 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
- 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
......
- 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