Commit 5c557194 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Martin Wortschack

Add user busy status to sidebar

Add the user busy status to the note header

Adds the busy status indicator to the sidebar
assignees dropdown and the note header in
dicusssions

Minor cleanup specs
parent 72b6be71
<script>
/* eslint-disable vue/no-v-html */
import { GlIcon, GlLoadingIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { isUserBusy } from '~/set_status_modal/utils';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue';
export default {
components: {
......@@ -12,7 +12,7 @@ export default {
import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
GlIcon,
GlLoadingIcon,
GlSprintf,
UserNameWithStatus,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -90,10 +90,6 @@ export default {
}
return false;
},
authorIsBusy() {
const { status } = this.author;
return status?.availability && isUserBusy(status.availability);
},
emojiElement() {
return this.$refs?.authorStatus?.querySelector('gl-emoji');
},
......@@ -133,6 +129,9 @@ export default {
this.$refs.authorNameLink.dispatchEvent(new Event('mouseleave'));
this.isUsernameLinkHovered = false;
},
userAvailability(selectedAuthor) {
return selectedAuthor?.availability || '';
},
},
};
</script>
......@@ -158,12 +157,11 @@ export default {
:data-username="author.username"
>
<slot name="note-header-info"></slot>
<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>
<user-name-with-status
:name="authorName"
:availability="userAvailability(author)"
container-classes="note-header-author-name gl-font-weight-bold"
/>
</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>
......@@ -10,7 +10,7 @@ import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { __, s__ } from '~/locale';
import { updateUserStatus } from '~/rest_api';
import EmojiMenuInModal from './emoji_menu_in_modal';
import { isUserBusy, isValidAvailibility } from './utils';
import { isUserBusy } from './utils';
const emojiMenuClass = 'js-modal-status-emoji-menu';
export const AVAILABILITY_STATUS = {
......@@ -46,7 +46,6 @@ export default {
currentAvailability: {
type: String,
required: false,
validator: isValidAvailibility,
default: '',
},
canSetUserAvailability: {
......
......@@ -3,7 +3,5 @@ export const AVAILABILITY_STATUS = {
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;
export const isUserBusy = (status = '') =>
Boolean(status.length && status.toLowerCase().trim() === AVAILABILITY_STATUS.BUSY);
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import AssigneeAvatar from './assignee_avatar.vue';
const I18N = {
BUSY: __('Busy'),
CANNOT_MERGE: __('Cannot merge'),
LC_CANNOT_MERGE: __('cannot merge'),
};
const paranthesize = (str) => `(${str})`;
const generateAssigneeTooltip = ({
name,
availability,
cannotMerge = true,
tooltipHasName = false,
}) => {
if (!tooltipHasName) {
return cannotMerge ? I18N.CANNOT_MERGE : '';
}
const statusInformation = [];
if (availability && isUserBusy(availability)) {
statusInformation.push(I18N.BUSY);
}
if (cannotMerge) {
statusInformation.push(I18N.LC_CANNOT_MERGE);
}
if (tooltipHasName && statusInformation.length) {
return sprintf(__('%{name} %{status}'), {
name,
status: statusInformation.map(paranthesize).join(' '),
});
}
return name;
};
export default {
components: {
AssigneeAvatar,
......@@ -37,15 +75,13 @@ export default {
return this.issuableType === 'merge_request' && !this.user.can_merge;
},
tooltipTitle() {
if (this.cannotMerge && this.tooltipHasName) {
return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name });
} else if (this.cannotMerge) {
return __('Cannot merge');
} else if (this.tooltipHasName) {
return this.user.name;
}
return '';
const { name = '', availability = '' } = this.user;
return generateAssigneeTooltip({
name,
availability,
cannotMerge: this.cannotMerge,
tooltipHasName: this.tooltipHasName,
});
},
tooltipOption() {
return {
......
......@@ -36,7 +36,6 @@ export default {
sortedAssigness() {
const canMergeUsers = this.users.filter((user) => user.can_merge);
const canNotMergeUsers = this.users.filter((user) => !user.can_merge);
return [...canMergeUsers, ...canNotMergeUsers];
},
},
......
<script>
import AssigneeAvatar from './assignee_avatar.vue';
import UserNameWithStatus from './user_name_with_status.vue';
export default {
components: {
AssigneeAvatar,
UserNameWithStatus,
},
props: {
user: {
......@@ -16,12 +18,20 @@ export default {
default: 'issue',
},
},
computed: {
availability() {
return this.user?.availability || '';
},
},
};
</script>
<template>
<button type="button" class="btn-link">
<assignee-avatar :user="user" :img-size="24" :issuable-type="issuableType" />
<span class="author"> {{ user.name }} </span>
<user-name-with-status
:name="user.name"
:availability="availability"
container-classes="author"
/>
</button>
</template>
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import CollapsedAssignee from './collapsed_assignee.vue';
const DEFAULT_MAX_COUNTER = 99;
const DEFAULT_RENDER_COUNT = 5;
const generateCollapsedAssigneeTooltip = ({ renderUsers, allUsers, tooltipTitleMergeStatus }) => {
const names = renderUsers.map(({ name, availability }) => {
if (availability && isUserBusy(availability)) {
return sprintf(__('%{name} (Busy)'), { name });
}
return name;
});
if (!allUsers.length) {
return __('Assignee(s)');
}
if (allUsers.length > names.length) {
names.push(sprintf(__('+ %{amount} more'), { amount: allUsers.length - names.length }));
}
const text = names.join(', ');
return tooltipTitleMergeStatus ? `${text} (${tooltipTitleMergeStatus})` : text;
};
export default {
directives: {
GlTooltip: GlTooltipDirective,
......@@ -74,19 +93,11 @@ export default {
tooltipTitle() {
const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length);
const renderUsers = this.users.slice(0, maxRender);
const names = renderUsers.map((u) => u.name);
if (!this.users.length) {
return __('Assignee(s)');
}
if (this.users.length > names.length) {
names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length }));
}
const text = names.join(', ');
return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text;
return generateCollapsedAssigneeTooltip({
renderUsers,
allUsers: this.users,
tooltipTitleMergeStatus: this.tooltipTitleMergeStatus,
});
},
tooltipOptions() {
......
<script>
import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
const DEFAULT_RENDER_COUNT = 5;
export default {
components: {
AssigneeAvatarLink,
UserNameWithStatus,
},
props: {
users: {
......@@ -55,6 +57,9 @@ export default {
toggleShowLess() {
this.showLess = !this.showLess;
},
userAvailability(u) {
return u?.availability || '';
},
},
};
</script>
......@@ -68,7 +73,7 @@ export default {
:issuable-type="issuableType"
>
<div class="ml-2 gl-line-height-normal">
<div>{{ firstUser.name }}</div>
<user-name-with-status :name="firstUser.name" :availability="userAvailability(firstUser)" />
<div>{{ username }}</div>
</div>
</assignee-avatar-link>
......
<script>
import { GlSprintf } from '@gitlab/ui';
import { isUserBusy } from '~/set_status_modal/utils';
export default {
name: 'UserNameWithStatus',
components: {
GlSprintf,
},
props: {
name: {
type: String,
required: true,
},
containerClasses: {
type: String,
required: false,
default: '',
},
availability: {
type: String,
required: false,
default: '',
},
},
computed: {
isBusy() {
return isUserBusy(this.availability);
},
},
};
</script>
<template>
<span :class="containerClasses">
<gl-sprintf v-if="isBusy" :message="s__('UserAvailability|%{author} (Busy)')">
<template #author>{{ name }}</template>
</gl-sprintf>
<template v-else>{{ name }}</template>
</span>
</template>
......@@ -6,7 +6,7 @@ import {
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlIcon,
} from '@gitlab/ui';
import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import { glEmojiTag } from '../../../emoji';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
......@@ -26,7 +26,7 @@ export default {
GlPopover,
GlSkeletonLoading,
UserAvatarImage,
UserAvailabilityStatus,
UserNameWithStatus,
},
props: {
target: {
......@@ -66,7 +66,7 @@ export default {
);
},
availabilityStatus() {
return this.user?.status?.availability || null;
return this.user?.status?.availability || '';
},
},
};
......@@ -93,11 +93,7 @@ export default {
<template v-else>
<div class="gl-mb-3">
<h5 class="gl-m-0">
{{ user.name }}
<user-availability-status
v-if="availabilityStatus"
:availability="availabilityStatus"
/>
<user-name-with-status :name="user.name" :availability="availabilityStatus" />
</h5>
<span class="gl-text-gray-500">@{{ user.username }}</span>
</div>
......
---
title: Display the user busy status in the MR sidebar
merge_request: 47769
author:
type: changed
import { shallowMount } from '@vue/test-utils';
import { GlModal, GlFormCheckbox } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ExportRequirementsModal from 'ee/requirements/components/export_requirements_modal.vue';
......
......@@ -653,6 +653,12 @@ msgstr ""
msgid "%{name_with_link} has run out of Shared Runner Pipeline minutes so no new jobs or pipelines in its projects will run."
msgstr ""
msgid "%{name} %{status}"
msgstr ""
msgid "%{name} (Busy)"
msgstr ""
msgid "%{name} contained %{resultsString}"
msgstr ""
......@@ -5075,6 +5081,9 @@ msgstr ""
msgid "Business metrics (Custom)"
msgstr ""
msgid "Busy"
msgstr ""
msgid "Buy License"
msgstr ""
......
......@@ -4,6 +4,7 @@ import { nextTick } from 'vue';
import Vuex from 'vuex';
import NoteHeader from '~/notes/components/note_header.vue';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -36,9 +37,7 @@ describe('NoteHeader component', () => {
username: 'root',
show_status: true,
status_tooltip_html: statusHtml,
status: {
availability: '',
},
availability: '',
};
const createComponent = (props) => {
......@@ -48,7 +47,7 @@ describe('NoteHeader component', () => {
actions,
}),
propsData: { ...props },
stubs: { GlSprintf },
stubs: { GlSprintf, UserNameWithStatus },
});
};
......@@ -110,7 +109,7 @@ describe('NoteHeader component', () => {
});
it('renders busy status if author availability is set', () => {
createComponent({ author: { ...author, status: { availability: AVAILABILITY_STATUS.BUSY } } });
createComponent({ author: { ...author, availability: AVAILABILITY_STATUS.BUSY } });
expect(wrapper.find('.js-user-link').text()).toContain('(Busy)');
});
......
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 { AVAILABILITY_STATUS, isUserBusy } from '~/set_status_modal/utils';
describe('Set status modal utils', () => {
describe('isUserBusy', () => {
it.each`
value | result
${''} | ${false}
${'fake status'} | ${false}
${AVAILABILITY_STATUS.NOT_SET} | ${false}
${AVAILABILITY_STATUS.BUSY} | ${true}
`('with $value returns $result', ({ value, result }) => {
expect(isUserBusy(value)).toBe(result);
});
});
});
......@@ -79,4 +79,34 @@ describe('AssigneeAvatarLink component', () => {
});
},
);
describe.each`
tooltipHasName | availability | canMerge | expected
${true} | ${'Busy'} | ${false} | ${'Root (Busy) (cannot merge)'}
${true} | ${'Busy'} | ${true} | ${'Root (Busy)'}
${true} | ${''} | ${false} | ${'Root (cannot merge)'}
${true} | ${''} | ${true} | ${'Root'}
${false} | ${'Busy'} | ${false} | ${'Cannot merge'}
${false} | ${'Busy'} | ${true} | ${''}
${false} | ${''} | ${false} | ${'Cannot merge'}
${false} | ${''} | ${true} | ${''}
`(
"with tooltipHasName=$tooltipHasName and availability='$availability' and canMerge=$canMerge",
({ tooltipHasName, availability, canMerge, expected }) => {
beforeEach(() => {
createComponent({
tooltipHasName,
user: {
...userDataMock(),
can_merge: canMerge,
availability,
},
});
});
it('sets tooltip to $expected', () => {
expect(findTooltipText()).toBe(expected);
});
},
);
});
......@@ -187,4 +187,26 @@ describe('CollapsedAssigneeList component', () => {
expect(findAvatarCounter().text()).toEqual(`${DEFAULT_MAX_COUNTER}+`);
});
});
const [busyUser] = UsersMockHelper.createNumberRandomUsers(1);
const [canMergeUser] = UsersMockHelper.createNumberRandomUsers(1);
busyUser.availability = 'busy';
canMergeUser.can_merge = true;
describe.each`
users | busy | canMerge | expected
${[busyUser, canMergeUser]} | ${1} | ${1} | ${`${busyUser.name} (Busy), ${canMergeUser.name} (1/2 can merge)`}
${[busyUser]} | ${1} | ${0} | ${`${busyUser.name} (Busy) (cannot merge)`}
${[canMergeUser]} | ${0} | ${1} | ${`${canMergeUser.name}`}
${[]} | ${0} | ${0} | ${'Assignee(s)'}
`(
'with $users.length users, $busy is busy and $canMerge that can merge',
({ users, expected }) => {
it('generates the tooltip text', () => {
createComponent({ users });
expect(getTooltipTitle()).toEqual(expected);
});
},
);
});
import { shallowMount } from '@vue/test-utils';
import AssigneeAvatar from '~/sidebar/components/assignees/assignee_avatar.vue';
import CollapsedAssignee from '~/sidebar/components/assignees/collapsed_assignee.vue';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import userDataMock from '../../user_data_mock';
const TEST_USER = userDataMock();
......@@ -18,6 +19,9 @@ describe('CollapsedAssignee assignee component', () => {
wrapper = shallowMount(CollapsedAssignee, {
propsData,
stubs: {
UserNameWithStatus,
},
});
}
......
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
const name = 'Goku';
const containerClasses = 'gl-cool-class gl-over-9000';
describe('UserNameWithStatus', () => {
let wrapper;
function createComponent(props = {}) {
return shallowMount(UserNameWithStatus, {
propsData: { name, containerClasses, ...props },
stubs: {
GlSprintf,
},
});
}
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('will render the users name', () => {
expect(wrapper.html()).toContain(name);
});
it('will not render "Busy"', () => {
expect(wrapper.html()).not.toContain('Busy');
});
it('will render all relevant containerClasses', () => {
const classes = wrapper.find('span').classes().join(' ');
expect(classes).toBe(containerClasses);
});
describe(`with availability="${AVAILABILITY_STATUS.BUSY}"`, () => {
beforeEach(() => {
wrapper = createComponent({ availability: AVAILABILITY_STATUS.BUSY });
});
it('will render "Busy"', () => {
expect(wrapper.html()).toContain('Goku (Busy)');
});
});
});
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui';
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';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue';
const DEFAULT_PROPS = {
......@@ -36,7 +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 findUserName = () => wrapper.find(UserNameWithStatus);
const createWrapper = (props = {}, options = {}) => {
wrapper = shallowMount(UserPopover, {
......@@ -47,7 +47,7 @@ describe('User Popover Component', () => {
},
stubs: {
GlSprintf,
UserAvailabilityStatus,
UserNameWithStatus,
},
...options,
});
......@@ -213,7 +213,7 @@ describe('User Popover Component', () => {
createWrapper({ user });
expect(findAvailabilityStatus().exists()).toBe(true);
expect(findUserName().exists()).toBe(true);
expect(wrapper.text()).toContain(user.name);
expect(wrapper.text()).toContain('(Busy)');
});
......
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