Commit c07e57bd authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Luke Duncalfe

Add clear user status dropdown

This dropdown gets added to the set user
status modal. It allows a user the option
to set a predefined range to clear their
status.
parent bd8e2856
...@@ -55,12 +55,13 @@ export function getUserProjects(userId, query, options, callback) { ...@@ -55,12 +55,13 @@ export function getUserProjects(userId, query, options, callback) {
.catch(() => flash(__('Something went wrong while fetching projects'))); .catch(() => flash(__('Something went wrong while fetching projects')));
} }
export function updateUserStatus({ emoji, message, availability }) { export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
const url = buildApiUrl(USER_POST_STATUS_PATH); const url = buildApiUrl(USER_POST_STATUS_PATH);
return axios.put(url, { return axios.put(url, {
emoji, emoji,
message, message,
availability, availability,
clear_status_after: clearStatusAfter,
}); });
} }
...@@ -46,6 +46,7 @@ function initStatusTriggers() { ...@@ -46,6 +46,7 @@ function initStatusTriggers() {
currentMessage, currentMessage,
currentAvailability, currentAvailability,
canSetUserAvailability, canSetUserAvailability,
currentClearStatusAfter,
} = setStatusModalWrapperEl.dataset; } = setStatusModalWrapperEl.dataset;
return { return {
...@@ -54,6 +55,7 @@ function initStatusTriggers() { ...@@ -54,6 +55,7 @@ function initStatusTriggers() {
currentMessage, currentMessage,
currentAvailability, currentAvailability,
canSetUserAvailability, canSetUserAvailability,
currentClearStatusAfter,
}; };
}, },
render(createElement) { render(createElement) {
...@@ -63,6 +65,7 @@ function initStatusTriggers() { ...@@ -63,6 +65,7 @@ function initStatusTriggers() {
currentMessage, currentMessage,
currentAvailability, currentAvailability,
canSetUserAvailability, canSetUserAvailability,
currentClearStatusAfter,
} = this; } = this;
return createElement(SetStatusModalWrapper, { return createElement(SetStatusModalWrapper, {
...@@ -72,6 +75,7 @@ function initStatusTriggers() { ...@@ -72,6 +75,7 @@ function initStatusTriggers() {
currentMessage, currentMessage,
currentAvailability, currentAvailability,
canSetUserAvailability, canSetUserAvailability,
currentClearStatusAfter,
}, },
}); });
}, },
......
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import { GlToast, GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui'; import {
GlToast,
GlModal,
GlTooltipDirective,
GlIcon,
GlFormCheckbox,
GlDropdown,
GlDropdownItem,
} from '@gitlab/ui';
import $ from 'jquery'; import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import * as Emoji from '~/emoji'; import * as Emoji from '~/emoji';
import { deprecatedCreateFlash as createFlash } from '~/flash'; import { deprecatedCreateFlash as createFlash } from '~/flash';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { __, s__ } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import { updateUserStatus } from '~/rest_api'; import { updateUserStatus } from '~/rest_api';
import { timeRanges } from '~/vue_shared/constants';
import EmojiMenuInModal from './emoji_menu_in_modal'; import EmojiMenuInModal from './emoji_menu_in_modal';
import { isUserBusy } from './utils'; import { isUserBusy } from './utils';
...@@ -20,11 +29,21 @@ export const AVAILABILITY_STATUS = { ...@@ -20,11 +29,21 @@ export const AVAILABILITY_STATUS = {
Vue.use(GlToast); Vue.use(GlToast);
const statusTimeRanges = [
{
label: __('Never'),
name: 'never',
},
...timeRanges,
];
export default { export default {
components: { components: {
GlIcon, GlIcon,
GlModal, GlModal,
GlFormCheckbox, GlFormCheckbox,
GlDropdown,
GlDropdownItem,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -53,6 +72,11 @@ export default { ...@@ -53,6 +72,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
currentClearStatusAfter: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
...@@ -65,6 +89,10 @@ export default { ...@@ -65,6 +89,10 @@ export default {
modalId: 'set-user-status-modal', modalId: 'set-user-status-modal',
noEmoji: true, noEmoji: true,
availability: isUserBusy(this.currentAvailability), availability: isUserBusy(this.currentAvailability),
clearStatusAfter: statusTimeRanges[0].label,
clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), {
date: this.currentClearStatusAfter,
}),
}; };
}, },
computed: { computed: {
...@@ -161,12 +189,16 @@ export default { ...@@ -161,12 +189,16 @@ export default {
this.setStatus(); this.setStatus();
}, },
setStatus() { setStatus() {
const { emoji, message, availability } = this; const { emoji, message, availability, clearStatusAfter } = this;
updateUserStatus({ updateUserStatus({
emoji, emoji,
message, message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET, availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
clearStatusAfter:
clearStatusAfter === statusTimeRanges[0].label
? null
: clearStatusAfter.replace(' ', '_'),
}) })
.then(this.onUpdateSuccess) .then(this.onUpdateSuccess)
.catch(this.onUpdateFail); .catch(this.onUpdateFail);
...@@ -183,7 +215,11 @@ export default { ...@@ -183,7 +215,11 @@ export default {
this.closeModal(); this.closeModal();
}, },
setClearStatusAfter(after) {
this.clearStatusAfter = after;
},
}, },
statusTimeRanges,
}; };
</script> </script>
...@@ -268,10 +304,31 @@ export default { ...@@ -268,10 +304,31 @@ export default {
</div> </div>
<div class="gl-display-flex"> <div class="gl-display-flex">
<span class="gl-text-gray-600 gl-ml-5"> <span class="gl-text-gray-600 gl-ml-5">
{{ s__('SetStatusModal|"Busy" will be shown next to your name') }} {{ s__('SetStatusModal|A busy indicator is shown next to your name and avatar.') }}
</span> </span>
</div> </div>
</div> </div>
<div class="form-group">
<div class="gl-display-flex gl-align-items-baseline">
<span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
<gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
<gl-dropdown-item
v-for="after in $options.statusTimeRanges"
:key="after.name"
:data-testid="after.name"
@click="setClearStatusAfter(after.label)"
>{{ after.label }}</gl-dropdown-item
>
</gl-dropdown>
</div>
<div
v-if="currentClearStatusAfter.length"
class="gl-mt-3 gl-text-gray-400 gl-font-sm"
data-testid="clear-status-at-message"
>
{{ clearStatusAfterMessage }}
</div>
</div>
</div> </div>
</div> </div>
</gl-modal> </gl-modal>
......
...@@ -159,13 +159,20 @@ module PageLayoutHelper ...@@ -159,13 +159,20 @@ module PageLayoutHelper
end end
def user_status_properties(user) def user_status_properties(user)
default_properties = { current_emoji: '', current_message: '', can_set_user_availability: Feature.enabled?(:set_user_availability_status, user, default_enabled: :yaml), default_emoji: UserStatus::DEFAULT_EMOJI } default_properties = {
current_emoji: '',
current_message: '',
can_set_user_availability: Feature.enabled?(:set_user_availability_status, user, default_enabled: :yaml),
default_emoji: UserStatus::DEFAULT_EMOJI
}
return default_properties unless user&.status return default_properties unless user&.status
default_properties.merge({ default_properties.merge({
current_emoji: user.status.emoji.to_s, current_emoji: user.status.emoji.to_s,
current_message: user.status.message.to_s, current_message: user.status.message.to_s,
current_availability: user.status.availability.to_s current_availability: user.status.availability.to_s,
current_clear_status_after: user.status.clear_status_at.to_s
}) })
end end
......
---
title: User Availability - Allow users to schedule un-setting of their status values
merge_request: 56649
author:
type: added
...@@ -106,7 +106,8 @@ To show private contributions: ...@@ -106,7 +106,8 @@ To show private contributions:
## Set your current status ## Set your current status
> Introduced in GitLab 11.2. > - Introduced in GitLab 11.2.
> - [Improved](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56649) in GitLab 13.10.
You can provide a custom status message for your user profile along with an emoji that describes it. You can provide a custom status message for your user profile along with an emoji that describes it.
This may be helpful when you are out of office or otherwise not available. This may be helpful when you are out of office or otherwise not available.
...@@ -119,6 +120,7 @@ To set your current status: ...@@ -119,6 +120,7 @@ To set your current status:
1. Select **Set status** or, if you have already set a status, **Edit status**. 1. Select **Set status** or, if you have already set a status, **Edit status**.
1. Set the desired emoji and status message. Status messages must be plain text and 100 characters or less. 1. Set the desired emoji and status message. Status messages must be plain text and 100 characters or less.
They can also contain emoji codes like, `I'm on vacation :palm_tree:`. They can also contain emoji codes like, `I'm on vacation :palm_tree:`.
1. Select a value from the **Clear status after** dropdown.
1. Select **Set status**. Alternatively, you can select **Remove status** to remove your user status entirely. 1. Select **Set status**. Alternatively, you can select **Remove status** to remove your user status entirely.
You can also set your current status by [using the API](../../api/users.md#user-status). You can also set your current status by [using the API](../../api/users.md#user-status).
......
...@@ -27700,7 +27700,7 @@ msgstr "" ...@@ -27700,7 +27700,7 @@ msgstr ""
msgid "SetPasswordToCloneLink|set a password" msgid "SetPasswordToCloneLink|set a password"
msgstr "" msgstr ""
msgid "SetStatusModal|\"Busy\" will be shown next to your name" msgid "SetStatusModal|A busy indicator is shown next to your name and avatar."
msgstr "" msgstr ""
msgid "SetStatusModal|Add status emoji" msgid "SetStatusModal|Add status emoji"
...@@ -27712,6 +27712,9 @@ msgstr "" ...@@ -27712,6 +27712,9 @@ msgstr ""
msgid "SetStatusModal|Clear status" msgid "SetStatusModal|Clear status"
msgstr "" msgstr ""
msgid "SetStatusModal|Clear status after"
msgstr ""
msgid "SetStatusModal|Edit status" msgid "SetStatusModal|Edit status"
msgstr "" msgstr ""
...@@ -27733,6 +27736,9 @@ msgstr "" ...@@ -27733,6 +27736,9 @@ msgstr ""
msgid "SetStatusModal|What's your status?" msgid "SetStatusModal|What's your status?"
msgstr "" msgstr ""
msgid "SetStatusModal|Your status resets on %{date}."
msgstr ""
msgid "Sets %{epic_ref} as parent epic." msgid "Sets %{epic_ref} as parent epic."
msgstr "" msgstr ""
......
...@@ -44,6 +44,7 @@ describe('SetStatusModalWrapper', () => { ...@@ -44,6 +44,7 @@ describe('SetStatusModalWrapper', () => {
const findNoEmojiPlaceholder = () => wrapper.find('.js-no-emoji-placeholder'); const findNoEmojiPlaceholder = () => wrapper.find('.js-no-emoji-placeholder');
const findToggleEmojiButton = () => wrapper.find('.js-toggle-emoji-menu'); const findToggleEmojiButton = () => wrapper.find('.js-toggle-emoji-menu');
const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox); const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox);
const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]');
const initModal = ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => { const initModal = ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => {
const modal = findModal(); const modal = findModal();
...@@ -57,18 +58,18 @@ describe('SetStatusModalWrapper', () => { ...@@ -57,18 +58,18 @@ describe('SetStatusModalWrapper', () => {
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}; };
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent();
return initModal();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
mockEmoji.restore(); mockEmoji.restore();
}); });
describe('with minimum props', () => { describe('with minimum props', () => {
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent();
return initModal();
});
it('sets the hidden status emoji field', () => { it('sets the hidden status emoji field', () => {
const field = findFormField('emoji'); const field = findFormField('emoji');
expect(field.exists()).toBe(true); expect(field.exists()).toBe(true);
...@@ -96,6 +97,14 @@ describe('SetStatusModalWrapper', () => { ...@@ -96,6 +97,14 @@ describe('SetStatusModalWrapper', () => {
findToggleEmojiButton().trigger('click'); findToggleEmojiButton().trigger('click');
expect(wrapper.vm.showEmojiMenu).toHaveBeenCalled(); expect(wrapper.vm.showEmojiMenu).toHaveBeenCalled();
}); });
it('displays the clear status at dropdown', () => {
expect(wrapper.find('[data-testid="clear-status-at-dropdown"]').exists()).toBe(true);
});
it('does not display the clear status at message', () => {
expect(findClearStatusAtMessage().exists()).toBe(false);
});
}); });
describe('with no currentMessage set', () => { describe('with no currentMessage set', () => {
...@@ -146,9 +155,28 @@ describe('SetStatusModalWrapper', () => { ...@@ -146,9 +155,28 @@ describe('SetStatusModalWrapper', () => {
}); });
}); });
describe('with currentClearStatusAfter set', () => {
beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent({ currentClearStatusAfter: '2021-01-01 00:00:00 UTC' });
return initModal();
});
it('displays the clear status at message', () => {
const clearStatusAtMessage = findClearStatusAtMessage();
expect(clearStatusAtMessage.exists()).toBe(true);
expect(clearStatusAtMessage.text()).toBe('Your status resets on 2021-01-01 00:00:00 UTC.');
});
});
describe('update status', () => { describe('update status', () => {
describe('succeeds', () => { describe('succeeds', () => {
beforeEach(() => { beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent();
await initModal();
jest.spyOn(UserApi, 'updateUserStatus').mockResolvedValue(); jest.spyOn(UserApi, 'updateUserStatus').mockResolvedValue();
}); });
...@@ -167,18 +195,26 @@ describe('SetStatusModalWrapper', () => { ...@@ -167,18 +195,26 @@ describe('SetStatusModalWrapper', () => {
// set the availability status // set the availability status
findAvailabilityCheckbox().vm.$emit('input', true); findAvailabilityCheckbox().vm.$emit('input', true);
// set the currentClearStatusAfter to 30 minutes
wrapper.find('[data-testid="thirtyMinutes"]').vm.$emit('click');
findModal().vm.$emit('ok'); findModal().vm.$emit('ok');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const commonParams = { emoji: defaultEmoji, message: defaultMessage }; const commonParams = {
emoji: defaultEmoji,
message: defaultMessage,
};
expect(UserApi.updateUserStatus).toHaveBeenCalledTimes(2); expect(UserApi.updateUserStatus).toHaveBeenCalledTimes(2);
expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(1, { expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(1, {
availability: AVAILABILITY_STATUS.NOT_SET, availability: AVAILABILITY_STATUS.NOT_SET,
clearStatusAfter: null,
...commonParams, ...commonParams,
}); });
expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(2, { expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(2, {
availability: AVAILABILITY_STATUS.BUSY, availability: AVAILABILITY_STATUS.BUSY,
clearStatusAfter: '30_minutes',
...commonParams, ...commonParams,
}); });
}); });
...@@ -208,7 +244,11 @@ describe('SetStatusModalWrapper', () => { ...@@ -208,7 +244,11 @@ describe('SetStatusModalWrapper', () => {
}); });
describe('with errors', () => { describe('with errors', () => {
beforeEach(() => { beforeEach(async () => {
mockEmoji = await initEmojiMock();
wrapper = createComponent();
await initModal();
jest.spyOn(UserApi, 'updateUserStatus').mockRejectedValue(); jest.spyOn(UserApi, 'updateUserStatus').mockRejectedValue();
}); });
......
...@@ -223,39 +223,37 @@ RSpec.describe PageLayoutHelper do ...@@ -223,39 +223,37 @@ RSpec.describe PageLayoutHelper do
end end
describe '#user_status_properties' do describe '#user_status_properties' do
using RSpec::Parameterized::TableSyntax
let(:user) { build(:user) } let(:user) { build(:user) }
availability_types = Types::AvailabilityEnum.enum subject { helper.user_status_properties(user) }
where(:message, :emoji, :availability) do context 'when the user has no status' do
"Some message" | UserStatus::DEFAULT_EMOJI | availability_types[:busy] it 'returns default properties' do
"Some message" | UserStatus::DEFAULT_EMOJI | availability_types[:not_set] is_expected.to eq({
"Some message" | "basketball" | availability_types[:busy] current_emoji: '',
"Some message" | "basketball" | availability_types[:not_set] current_message: '',
"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, can_set_user_availability: true,
current_availability: availability,
current_emoji: emoji,
current_message: message,
default_emoji: UserStatus::DEFAULT_EMOJI default_emoji: UserStatus::DEFAULT_EMOJI
} })
end
end
context 'when user has a status' do
let(:time) { 3.hours.ago }
expect(helper.user_status_properties(user)).to eq(result) before do
user.status = UserStatus.new(message: 'Some message', emoji: 'basketball', availability: 'busy', clear_status_at: time)
end
it 'merges the status properties with the defaults' do
is_expected.to eq({
current_clear_status_after: time.to_s,
current_availability: 'busy',
current_emoji: 'basketball',
current_message: 'Some message',
can_set_user_availability: true,
default_emoji: UserStatus::DEFAULT_EMOJI
})
end end
end end
end end
......
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