Commit 6ad18d36 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '263452-add-busy-status-next-to-name' into 'master'

[FE] Set user availability - Add busy indicator to username

See merge request gitlab-org/gitlab!47387
parents 53a4a4b5 f96d2118
import $ from 'jquery';
import '~/lib/utils/jquery_at_who';
import { escape, template } from 'lodash';
import { s__ } from '~/locale';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import { isUserBusy } from '~/set_status_modal/utils';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
import { spriteIcon } from './lib/utils/common_utils';
......@@ -39,6 +41,7 @@ export function membersBeforeSave(members) {
title: sanitize(title),
search: sanitize(`${member.username} ${member.name}`),
icon: avatarIcon,
availability: member.availability,
};
});
}
......@@ -253,13 +256,17 @@ class GfmAutoComplete {
alias: 'users',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
const { avatarTag, username, title, icon } = value;
const { avatarTag, username, title, icon, availability } = value;
if (username != null) {
tmpl = GfmAutoComplete.Members.templateFunction({
avatarTag,
username,
title,
icon,
availabilityStatus:
availability && isUserBusy(availability)
? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>`
: '',
});
}
return tmpl;
......@@ -775,8 +782,10 @@ GfmAutoComplete.Emoji = {
};
// Team Members
GfmAutoComplete.Members = {
templateFunction({ avatarTag, username, title, icon }) {
return `<li>${avatarTag} ${username} <small>${escape(title)}</small> ${icon}</li>`;
templateFunction({ avatarTag, username, title, icon, availabilityStatus }) {
return `<li>${avatarTag} ${username} <small>${escape(
title,
)}${availabilityStatus}</small> ${icon}</li>`;
},
};
GfmAutoComplete.Labels = {
......
<script>
/* eslint-disable vue/no-v-html */
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';
export default {
......@@ -11,6 +12,7 @@ export default {
import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
GlIcon,
GlLoadingIcon,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -85,9 +87,16 @@ export default {
authorStatus() {
return this.author.status_tooltip_html;
},
authorIsBusy() {
const { status } = this.author;
return status?.availability && isUserBusy(status.availability);
},
emojiElement() {
return this.$refs?.authorStatus?.querySelector('gl-emoji');
},
authorName() {
return this.author.name;
},
},
mounted() {
this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : '';
......@@ -146,7 +155,12 @@ export default {
:data-username="author.username"
>
<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>
<span
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';
import { __, s__ } from '~/locale';
import Api from '~/api';
import EmojiMenuInModal from './emoji_menu_in_modal';
import { isUserBusy, isValidAvailibility } from './utils';
import * as Emoji from '~/emoji';
const emojiMenuClass = 'js-modal-status-emoji-menu';
......@@ -28,6 +29,17 @@ export default {
type: String,
required: true,
},
currentAvailability: {
type: String,
required: false,
validator: isValidAvailibility,
default: '',
},
canSetUserAvailability: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -39,6 +51,7 @@ export default {
message: this.currentMessage,
modalId: 'set-user-status-modal',
noEmoji: true,
availability: isUserBusy(this.currentAvailability),
};
},
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 {
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlIcon,
} from '@gitlab/ui';
import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
......@@ -25,6 +26,7 @@ export default {
GlPopover,
GlSkeletonLoading,
UserAvatarImage,
UserAvailabilityStatus,
},
props: {
target: {
......@@ -63,6 +65,9 @@ export default {
websiteUrl.length
);
},
availabilityStatus() {
return this.user?.status?.availability || null;
},
},
};
</script>
......@@ -89,6 +94,10 @@ export default {
<div class="gl-mb-3">
<h5 class="gl-m-0">
{{ user.name }}
<user-availability-status
v-if="availabilityStatus"
:availability="availabilityStatus"
/>
</h5>
<span class="gl-text-gray-500">@{{ user.username }}</span>
</div>
......
......@@ -29,4 +29,12 @@ module ProfilesHelper
def user_profile?
params[:controller] == 'users'
end
def availability_values
Types::AvailabilityEnum.enum
end
def user_status_set_to_busy?(status)
status&.availability == availability_values[:busy]
end
end
......@@ -45,7 +45,8 @@ module Users
type: user.class.name,
username: user.username,
name: user.name,
avatar_url: user.avatar_url
avatar_url: user.avatar_url,
availability: user&.status&.availability
}
end
......
......@@ -2,8 +2,10 @@
%ul
%li.current-user
.user-name.bold
.user-name.gl-font-weight-bold
= 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
- 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' } }
......
......@@ -47,6 +47,8 @@
.user-info
.cover-title{ itemprop: '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
.cover-status
......
......@@ -32,7 +32,7 @@ exports[`Event Item with action buttons renders the action buttons 1`] = `
>
<span
class="note-header-author-name bold"
class="note-header-author-name gl-font-weight-bold"
>
Tanuki
</span>
......
......@@ -16,7 +16,8 @@ RSpec.describe Groups::ParticipantsService do
type: user.class.name,
username: user.username,
name: user.name,
avatar_url: user.avatar_url
avatar_url: user.avatar_url,
availability: user&.status&.availability
}
end
......
......@@ -29489,6 +29489,12 @@ msgstr ""
msgid "User was successfully updated."
msgstr ""
msgid "UserAvailability|%{author} (Busy)"
msgstr ""
msgid "UserAvailability|(Busy)"
msgstr ""
msgid "UserLists|Add"
msgstr ""
......@@ -29546,6 +29552,9 @@ msgstr ""
msgid "UserList|created %{timeago}"
msgstr ""
msgid "UserProfile|(Busy)"
msgstr ""
msgid "UserProfile|Activity"
msgstr ""
......
......@@ -378,6 +378,7 @@ describe('GfmAutoComplete', () => {
username: 'my-group',
title: '',
icon: '',
availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small></small> </li>');
});
......@@ -389,6 +390,7 @@ describe('GfmAutoComplete', () => {
username: 'my-group',
title: '',
icon: '<i class="icon"/>',
availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
});
......@@ -400,9 +402,24 @@ describe('GfmAutoComplete', () => {
username: 'my-group',
title: 'MyGroup+',
icon: '<i class="icon"/>',
availabilityStatus: '',
}),
).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', () => {
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex';
import { GlSprintf } from '@gitlab/ui';
import NoteHeader from '~/notes/components/note_header.vue';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -28,6 +30,9 @@ describe('NoteHeader component', () => {
path: '/root',
state: 'active',
username: 'root',
status: {
availability: '',
},
};
const createComponent = props => {
......@@ -37,6 +42,7 @@ describe('NoteHeader component', () => {
actions,
}),
propsData: { ...props },
stubs: { GlSprintf },
});
};
......@@ -97,6 +103,12 @@ describe('NoteHeader component', () => {
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', () => {
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 { shallowMount } from '@vue/test-utils';
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 = {
user: {
......@@ -34,6 +36,7 @@ describe('User Popover Component', () => {
const findByTestId = testid => wrapper.find(`[data-testid="${testid}"]`);
const findUserStatus = () => wrapper.find('.js-user-status');
const findTarget = () => document.querySelector('.js-user-link');
const findAvailabilityStatus = () => wrapper.find(UserAvailabilityStatus);
const createWrapper = (props = {}, options = {}) => {
wrapper = shallowMount(UserPopover, {
......@@ -43,7 +46,8 @@ describe('User Popover Component', () => {
...props,
},
stubs: {
'gl-sprintf': GlSprintf,
GlSprintf,
UserAvailabilityStatus,
},
...options,
});
......@@ -199,6 +203,30 @@ describe('User Popover Component', () => {
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', () => {
......
......@@ -80,6 +80,21 @@ RSpec.describe ProfilesHelper do
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
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