Commit f96d2118 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Martin Wortschack

Display busy status in user popover

Minor refactor to reuse some of the
methods for user availability
parent e7650bc4
import $ from 'jquery'; import $ from 'jquery';
import '~/lib/utils/jquery_at_who'; import '~/lib/utils/jquery_at_who';
import { escape, template } from 'lodash'; import { escape, template } from 'lodash';
import { s__ } from '~/locale';
import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarMediator from '~/sidebar/sidebar_mediator';
import { isUserBusy } from '~/set_status_modal/utils';
import glRegexp from './lib/utils/regexp'; import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache'; import AjaxCache from './lib/utils/ajax_cache';
import { spriteIcon } from './lib/utils/common_utils'; import { spriteIcon } from './lib/utils/common_utils';
...@@ -39,6 +41,7 @@ export function membersBeforeSave(members) { ...@@ -39,6 +41,7 @@ export function membersBeforeSave(members) {
title: sanitize(title), title: sanitize(title),
search: sanitize(`${member.username} ${member.name}`), search: sanitize(`${member.username} ${member.name}`),
icon: avatarIcon, icon: avatarIcon,
availability: member.availability,
}; };
}); });
} }
...@@ -253,13 +256,17 @@ class GfmAutoComplete { ...@@ -253,13 +256,17 @@ class GfmAutoComplete {
alias: 'users', alias: 'users',
displayTpl(value) { displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template; let tmpl = GfmAutoComplete.Loading.template;
const { avatarTag, username, title, icon } = value; const { avatarTag, username, title, icon, availability } = value;
if (username != null) { if (username != null) {
tmpl = GfmAutoComplete.Members.templateFunction({ tmpl = GfmAutoComplete.Members.templateFunction({
avatarTag, avatarTag,
username, username,
title, title,
icon, icon,
availabilityStatus:
availability && isUserBusy(availability)
? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>`
: '',
}); });
} }
return tmpl; return tmpl;
...@@ -775,8 +782,10 @@ GfmAutoComplete.Emoji = { ...@@ -775,8 +782,10 @@ GfmAutoComplete.Emoji = {
}; };
// Team Members // Team Members
GfmAutoComplete.Members = { GfmAutoComplete.Members = {
templateFunction({ avatarTag, username, title, icon }) { templateFunction({ avatarTag, username, title, icon, availabilityStatus }) {
return `<li>${avatarTag} ${username} <small>${escape(title)}</small> ${icon}</li>`; return `<li>${avatarTag} ${username} <small>${escape(
title,
)}${availabilityStatus}</small> ${icon}</li>`;
}, },
}; };
GfmAutoComplete.Labels = { GfmAutoComplete.Labels = {
......
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlIcon, GlLoadingIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
import { isUserBusy } from '~/set_status_modal/utils';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default { export default {
...@@ -11,6 +12,7 @@ export default { ...@@ -11,6 +12,7 @@ export default {
import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'), import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
GlIcon, GlIcon,
GlLoadingIcon, GlLoadingIcon,
GlSprintf,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -85,9 +87,16 @@ export default { ...@@ -85,9 +87,16 @@ export default {
authorStatus() { authorStatus() {
return this.author.status_tooltip_html; return this.author.status_tooltip_html;
}, },
authorIsBusy() {
const { status } = this.author;
return status?.availability && isUserBusy(status.availability);
},
emojiElement() { emojiElement() {
return this.$refs?.authorStatus?.querySelector('gl-emoji'); return this.$refs?.authorStatus?.querySelector('gl-emoji');
}, },
authorName() {
return this.author.name;
},
}, },
mounted() { mounted() {
this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : ''; this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : '';
...@@ -146,7 +155,12 @@ export default { ...@@ -146,7 +155,12 @@ export default {
:data-username="author.username" :data-username="author.username"
> >
<slot name="note-header-info"></slot> <slot name="note-header-info"></slot>
<span class="note-header-author-name bold">{{ author.name }}</span> <span class="note-header-author-name gl-font-weight-bold">
<gl-sprintf v-if="authorIsBusy" :message="s__('UserAvailability|%{author} (Busy)')">
<template #author>{{ authorName }}</template>
</gl-sprintf>
<template v-else>{{ authorName }}</template>
</span>
</a> </a>
<span <span
v-if="authorStatus" v-if="authorStatus"
......
<script>
import { AVAILABILITY_STATUS, isUserBusy, isValidAvailibility } from '../utils';
export default {
name: 'UserAvailabilityStatus',
props: {
availability: {
type: String,
required: true,
validator: isValidAvailibility,
},
},
computed: {
isBusy() {
const { availability = AVAILABILITY_STATUS.NOT_SET } = this;
return isUserBusy(availability);
},
},
};
</script>
<template>
<span v-if="isBusy" class="gl-font-weight-normal gl-text-gray-500">{{
s__('UserAvailability|(Busy)')
}}</span>
</template>
...@@ -7,6 +7,7 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; ...@@ -7,6 +7,7 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import Api from '~/api'; import Api from '~/api';
import EmojiMenuInModal from './emoji_menu_in_modal'; import EmojiMenuInModal from './emoji_menu_in_modal';
import { isUserBusy, isValidAvailibility } from './utils';
import * as Emoji from '~/emoji'; import * as Emoji from '~/emoji';
const emojiMenuClass = 'js-modal-status-emoji-menu'; const emojiMenuClass = 'js-modal-status-emoji-menu';
...@@ -28,6 +29,17 @@ export default { ...@@ -28,6 +29,17 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
currentAvailability: {
type: String,
required: false,
validator: isValidAvailibility,
default: '',
},
canSetUserAvailability: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -39,6 +51,7 @@ export default { ...@@ -39,6 +51,7 @@ export default {
message: this.currentMessage, message: this.currentMessage,
modalId: 'set-user-status-modal', modalId: 'set-user-status-modal',
noEmoji: true, noEmoji: true,
availability: isUserBusy(this.currentAvailability),
}; };
}, },
computed: { computed: {
......
export const AVAILABILITY_STATUS = {
BUSY: 'busy',
NOT_SET: 'not_set',
};
export const isUserBusy = status => status === AVAILABILITY_STATUS.BUSY;
export const isValidAvailibility = availability =>
availability.length ? Object.values(AVAILABILITY_STATUS).includes(availability) : true;
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlIcon, GlIcon,
} from '@gitlab/ui'; } from '@gitlab/ui';
import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji'; import { glEmojiTag } from '../../../emoji';
...@@ -25,6 +26,7 @@ export default { ...@@ -25,6 +26,7 @@ export default {
GlPopover, GlPopover,
GlSkeletonLoading, GlSkeletonLoading,
UserAvatarImage, UserAvatarImage,
UserAvailabilityStatus,
}, },
props: { props: {
target: { target: {
...@@ -63,6 +65,9 @@ export default { ...@@ -63,6 +65,9 @@ export default {
websiteUrl.length websiteUrl.length
); );
}, },
availabilityStatus() {
return this.user?.status?.availability || null;
},
}, },
}; };
</script> </script>
...@@ -89,6 +94,10 @@ export default { ...@@ -89,6 +94,10 @@ export default {
<div class="gl-mb-3"> <div class="gl-mb-3">
<h5 class="gl-m-0"> <h5 class="gl-m-0">
{{ user.name }} {{ user.name }}
<user-availability-status
v-if="availabilityStatus"
:availability="availabilityStatus"
/>
</h5> </h5>
<span class="gl-text-gray-500">@{{ user.username }}</span> <span class="gl-text-gray-500">@{{ user.username }}</span>
</div> </div>
......
...@@ -29,4 +29,12 @@ module ProfilesHelper ...@@ -29,4 +29,12 @@ module ProfilesHelper
def user_profile? def user_profile?
params[:controller] == 'users' params[:controller] == 'users'
end end
def availability_values
Types::AvailabilityEnum.enum
end
def user_status_set_to_busy?(status)
status&.availability == availability_values[:busy]
end
end end
...@@ -45,7 +45,8 @@ module Users ...@@ -45,7 +45,8 @@ module Users
type: user.class.name, type: user.class.name,
username: user.username, username: user.username,
name: user.name, name: user.name,
avatar_url: user.avatar_url avatar_url: user.avatar_url,
availability: user&.status&.availability
} }
end end
......
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
%ul %ul
%li.current-user %li.current-user
.user-name.bold .user-name.gl-font-weight-bold
= current_user.name = current_user.name
- if current_user&.status && user_status_set_to_busy?(current_user.status)
%span.gl-font-weight-normal.gl-text-gray-500= s_("UserProfile|(Busy)")
= current_user.to_reference = current_user.to_reference
- if current_user.status - 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' } } .user-status.d-flex.align-items-center.gl-mt-2.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } }
......
...@@ -47,6 +47,8 @@ ...@@ -47,6 +47,8 @@
.user-info .user-info
.cover-title{ itemprop: 'name' } .cover-title{ itemprop: 'name' }
= @user.name = @user.name
- 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 @user.status
.cover-status .cover-status
......
...@@ -32,7 +32,7 @@ exports[`Event Item with action buttons renders the action buttons 1`] = ` ...@@ -32,7 +32,7 @@ exports[`Event Item with action buttons renders the action buttons 1`] = `
> >
<span <span
class="note-header-author-name bold" class="note-header-author-name gl-font-weight-bold"
> >
Tanuki Tanuki
</span> </span>
......
...@@ -16,7 +16,8 @@ RSpec.describe Groups::ParticipantsService do ...@@ -16,7 +16,8 @@ RSpec.describe Groups::ParticipantsService do
type: user.class.name, type: user.class.name,
username: user.username, username: user.username,
name: user.name, name: user.name,
avatar_url: user.avatar_url avatar_url: user.avatar_url,
availability: user&.status&.availability
} }
end end
......
...@@ -29501,6 +29501,12 @@ msgstr "" ...@@ -29501,6 +29501,12 @@ msgstr ""
msgid "User was successfully updated." msgid "User was successfully updated."
msgstr "" msgstr ""
msgid "UserAvailability|%{author} (Busy)"
msgstr ""
msgid "UserAvailability|(Busy)"
msgstr ""
msgid "UserLists|Add" msgid "UserLists|Add"
msgstr "" msgstr ""
...@@ -29558,6 +29564,9 @@ msgstr "" ...@@ -29558,6 +29564,9 @@ msgstr ""
msgid "UserList|created %{timeago}" msgid "UserList|created %{timeago}"
msgstr "" msgstr ""
msgid "UserProfile|(Busy)"
msgstr ""
msgid "UserProfile|Activity" msgid "UserProfile|Activity"
msgstr "" msgstr ""
......
...@@ -378,6 +378,7 @@ describe('GfmAutoComplete', () => { ...@@ -378,6 +378,7 @@ describe('GfmAutoComplete', () => {
username: 'my-group', username: 'my-group',
title: '', title: '',
icon: '', icon: '',
availabilityStatus: '',
}), }),
).toBe('<li>IMG my-group <small></small> </li>'); ).toBe('<li>IMG my-group <small></small> </li>');
}); });
...@@ -389,6 +390,7 @@ describe('GfmAutoComplete', () => { ...@@ -389,6 +390,7 @@ describe('GfmAutoComplete', () => {
username: 'my-group', username: 'my-group',
title: '', title: '',
icon: '<i class="icon"/>', icon: '<i class="icon"/>',
availabilityStatus: '',
}), }),
).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>'); ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
}); });
...@@ -400,9 +402,24 @@ describe('GfmAutoComplete', () => { ...@@ -400,9 +402,24 @@ describe('GfmAutoComplete', () => {
username: 'my-group', username: 'my-group',
title: 'MyGroup+', title: 'MyGroup+',
icon: '<i class="icon"/>', icon: '<i class="icon"/>',
availabilityStatus: '',
}), }),
).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>'); ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>');
}); });
it('should add user availability status if availabilityStatus is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '<i class="icon"/>',
availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>',
}),
).toBe(
'<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>',
);
});
}); });
describe('labels', () => { describe('labels', () => {
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { GlSprintf } from '@gitlab/ui';
import NoteHeader from '~/notes/components/note_header.vue'; import NoteHeader from '~/notes/components/note_header.vue';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -28,6 +30,9 @@ describe('NoteHeader component', () => { ...@@ -28,6 +30,9 @@ describe('NoteHeader component', () => {
path: '/root', path: '/root',
state: 'active', state: 'active',
username: 'root', username: 'root',
status: {
availability: '',
},
}; };
const createComponent = props => { const createComponent = props => {
...@@ -37,6 +42,7 @@ describe('NoteHeader component', () => { ...@@ -37,6 +42,7 @@ describe('NoteHeader component', () => {
actions, actions,
}), }),
propsData: { ...props }, propsData: { ...props },
stubs: { GlSprintf },
}); });
}; };
...@@ -97,6 +103,12 @@ describe('NoteHeader component', () => { ...@@ -97,6 +103,12 @@ describe('NoteHeader component', () => {
expect(wrapper.find('.js-user-link').exists()).toBe(true); expect(wrapper.find('.js-user-link').exists()).toBe(true);
}); });
it('renders busy status if author availability is set', () => {
createComponent({ author: { ...author, status: { availability: AVAILABILITY_STATUS.BUSY } } });
expect(wrapper.find('.js-user-link').text()).toContain('(Busy)');
});
it('renders deleted user text if author is not passed as a prop', () => { it('renders deleted user text if author is not passed as a prop', () => {
createComponent(); createComponent();
......
import { shallowMount } from '@vue/test-utils';
import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
describe('UserAvailabilityStatus', () => {
let wrapper;
const createComponent = (props = {}) => {
return shallowMount(UserAvailabilityStatus, {
propsData: {
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('with availability status', () => {
it(`set to ${AVAILABILITY_STATUS.BUSY}`, () => {
wrapper = createComponent({ availability: AVAILABILITY_STATUS.BUSY });
expect(wrapper.text()).toContain('(Busy)');
});
it(`set to ${AVAILABILITY_STATUS.NOT_SET}`, () => {
wrapper = createComponent({ availability: AVAILABILITY_STATUS.NOT_SET });
expect(wrapper.html()).toBe('');
});
});
});
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue';
import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
user: { user: {
...@@ -34,6 +36,7 @@ describe('User Popover Component', () => { ...@@ -34,6 +36,7 @@ describe('User Popover Component', () => {
const findByTestId = testid => wrapper.find(`[data-testid="${testid}"]`); const findByTestId = testid => wrapper.find(`[data-testid="${testid}"]`);
const findUserStatus = () => wrapper.find('.js-user-status'); const findUserStatus = () => wrapper.find('.js-user-status');
const findTarget = () => document.querySelector('.js-user-link'); const findTarget = () => document.querySelector('.js-user-link');
const findAvailabilityStatus = () => wrapper.find(UserAvailabilityStatus);
const createWrapper = (props = {}, options = {}) => { const createWrapper = (props = {}, options = {}) => {
wrapper = shallowMount(UserPopover, { wrapper = shallowMount(UserPopover, {
...@@ -43,7 +46,8 @@ describe('User Popover Component', () => { ...@@ -43,7 +46,8 @@ describe('User Popover Component', () => {
...props, ...props,
}, },
stubs: { stubs: {
'gl-sprintf': GlSprintf, GlSprintf,
UserAvailabilityStatus,
}, },
...options, ...options,
}); });
...@@ -199,6 +203,30 @@ describe('User Popover Component', () => { ...@@ -199,6 +203,30 @@ describe('User Popover Component', () => {
expect(findUserStatus().exists()).toBe(false); expect(findUserStatus().exists()).toBe(false);
}); });
it('should show the busy status if user set to busy', () => {
const user = {
...DEFAULT_PROPS.user,
status: { availability: AVAILABILITY_STATUS.BUSY },
};
createWrapper({ user });
expect(findAvailabilityStatus().exists()).toBe(true);
expect(wrapper.text()).toContain(user.name);
expect(wrapper.text()).toContain('(Busy)');
});
it('should hide the busy status for any other status', () => {
const user = {
...DEFAULT_PROPS.user,
status: { availability: AVAILABILITY_STATUS.NOT_SET },
};
createWrapper({ user });
expect(wrapper.text()).not.toContain('(Busy)');
});
}); });
describe('security bot', () => { describe('security bot', () => {
......
...@@ -80,6 +80,21 @@ RSpec.describe ProfilesHelper do ...@@ -80,6 +80,21 @@ RSpec.describe ProfilesHelper do
end end
end end
describe "#user_status_set_to_busy?" do
using RSpec::Parameterized::TableSyntax
where(:availability, :result) do
"busy" | true
"not_set" | false
"" | false
nil | false
end
with_them do
it { expect(helper.user_status_set_to_busy?(OpenStruct.new(availability: availability))).to eq(result) }
end
end
def stub_cas_omniauth_provider def stub_cas_omniauth_provider
provider = OpenStruct.new( provider = OpenStruct.new(
'name' => 'cas3', '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