Commit a98fec6d authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '263452-sidebar-display-busy-status' into 'master'

[FE] Set user availability - Add busy status to MR sidebar

See merge request gitlab-org/gitlab!47769
parents 14d6ca06 5c557194
<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
......@@ -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