Commit 82e56d7d authored by David O'Regan's avatar David O'Regan Committed by Illya Klymov

Alert Management assignee avatar

Add assignee avatars to Alert management
in the list view and sidebar view.
parent c1b1d1fa
......@@ -4,6 +4,9 @@ import {
GlLoadingIcon,
GlTable,
GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
GlIcon,
GlLink,
GlTabs,
......@@ -12,6 +15,7 @@ import {
GlPagination,
GlSearchBoxByType,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import { debounce, trim } from 'lodash';
import { __, s__ } from '~/locale';
......@@ -57,6 +61,7 @@ export default {
"AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
),
searchPlaceholder: __('Search or filter results...'),
unassigned: __('Unassigned'),
},
fields: [
{
......@@ -113,6 +118,9 @@ export default {
GlLoadingIcon,
GlTable,
GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
TimeAgo,
GlIcon,
GlLink,
......@@ -124,6 +132,9 @@ export default {
GlSprintf,
AlertStatus,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
projectPath: {
type: String,
......@@ -267,11 +278,8 @@ export default {
const { category, action, label } = trackAlertStatusUpdateOptions;
Tracking.event(category, action, { label, property: status });
},
getAssignees(assignees) {
// TODO: Update to show list of assignee(s) after https://gitlab.com/gitlab-org/gitlab/-/issues/218405
return assignees.nodes?.length > 0
? assignees.nodes[0]?.username
: s__('AlertManagement|Unassigned');
hasAssignees(assignees) {
return Boolean(assignees.nodes?.length);
},
getIssueLink(item) {
return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid);
......@@ -418,8 +426,32 @@ export default {
</template>
<template #cell(assignees)="{ item }">
<div class="gl-max-w-full text-truncate" data-testid="assigneesField">
{{ getAssignees(item.assignees) }}
<div data-testid="assigneesField">
<template v-if="hasAssignees(item.assignees)">
<gl-avatars-inline
:avatars="item.assignees.nodes"
:collapsed="true"
:max-visible="4"
:avatar-size="24"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="100"
>
<template #avatar="{ avatar }">
<gl-avatar-link
:key="avatar.username"
v-gl-tooltip
target="_blank"
:href="avatar.webUrl"
:title="avatar.name"
>
<gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" />
</gl-avatar-link>
</template>
</gl-avatars-inline>
</template>
<template v-else>
{{ $options.i18n.unassigned }}
</template>
</div>
</template>
......
......@@ -12,7 +12,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { s__, __ } from '~/locale';
import { s__ } from '~/locale';
import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql';
import SidebarAssignee from './sidebar_assignee.vue';
......@@ -82,8 +82,11 @@ export default {
userName() {
return this.alert?.assignees?.nodes[0]?.username;
},
assignedUser() {
return this.userName || __('None');
userFullName() {
return this.alert?.assignees?.nodes[0]?.name;
},
userImg() {
return this.alert?.assignees?.nodes[0]?.avatarUrl;
},
sortedUsers() {
return this.users
......@@ -184,15 +187,15 @@ export default {
</script>
<template>
<div class="block alert-status">
<div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
<div class="block alert-assignees ">
<div ref="assignees" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
<gl-icon name="user" :size="14" />
<gl-loading-icon v-if="isUpdating" />
</div>
<gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
<gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left">
<gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK">
<template #assignees>
{{ assignedUser }}
{{ userName }}
</template>
</gl-sprintf>
</gl-tooltip>
......@@ -215,7 +218,7 @@ export default {
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
<gl-deprecated-dropdown
ref="dropdown"
:text="assignedUser"
:text="userName"
class="w-100"
toggle-class="dropdown-menu-toggle"
variant="outline-default"
......@@ -272,14 +275,28 @@ export default {
</div>
<gl-loading-icon v-if="isUpdating" :inline="true" />
<p v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }">
<span v-if="userName" class="gl-text-gray-500" data-testid="assigned-users">{{
assignedUser
}}</span>
<span v-else class="gl-display-flex gl-align-items-center">
<div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }">
<div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users">
<span class="gl-relative mr-2">
<img
:alt="userName"
:src="userImg"
:width="32"
class="avatar avatar-inline gl-m-0 s32"
data-qa-selector="avatar_image"
/>
</span>
<span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden">
<strong class="dropdown-menu-user-full-name">
{{ userFullName }}
</strong>
<span class="dropdown-menu-user-username">{{ userName }}</span>
</span>
</div>
<span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal">
{{ __('None') }} -
<gl-button
class="gl-pl-2"
class="gl-ml-2"
href="#"
variant="link"
data-testid="unassigned-users"
......@@ -288,7 +305,7 @@ export default {
{{ __('assign yourself') }}
</gl-button>
</span>
</p>
</div>
</div>
</div>
</template>
......@@ -8,7 +8,10 @@ fragment AlertListItem on AlertManagementAlert {
issueIid
assignees {
nodes {
name
username
avatarUrl
webUrl
}
}
}
......@@ -10,6 +10,9 @@ mutation alertSetAssignees($projectPath: ID!, $assigneeUsernames: [String!]!, $i
assignees {
nodes {
username
name
avatarUrl
webUrl
}
}
notes {
......
---
title: Add Alert Management assignee avatar for list and details view
merge_request: 40275
author:
type: changed
......@@ -2246,9 +2246,6 @@ msgstr ""
msgid "AlertManagement|Triggered"
msgstr ""
msgid "AlertManagement|Unassigned"
msgstr ""
msgid "AlertManagement|Unknown"
msgstr ""
......
......@@ -11,6 +11,7 @@ import {
GlBadge,
GlPagination,
GlSearchBoxByType,
GlAvatar,
} from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import { visitUrl } from '~/lib/utils/url_utility';
......@@ -268,18 +269,22 @@ describe('AlertManagementTable', () => {
).toBe('Unassigned');
});
it('renders username(s) when assignee(s) present', () => {
it('renders user avatar when assignee present', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false,
});
expect(
findAssignees()
const avatar = findAssignees()
.at(1)
.text(),
).toBe(mockAlerts[1].assignees.nodes[0].username);
.find(GlAvatar);
const { src, label } = avatar.attributes();
const { name, avatarUrl } = mockAlerts[1].assignees.nodes[0];
expect(avatar.exists()).toBe(true);
expect(label).toBe(name);
expect(src).toBe(avatarUrl);
});
it('navigates to the detail page when alert row is clicked', () => {
......
......@@ -56,6 +56,9 @@ describe('Alert Details Sidebar Assignees', () => {
mock.restore();
});
const findAssigned = () => wrapper.find('[data-testid="assigned-users"]');
const findUnassigned = () => wrapper.find('[data-testid="unassigned-users"]');
describe('updating the alert status', () => {
const mockUpdatedMutationResult = {
data: {
......@@ -100,18 +103,17 @@ describe('Alert Details Sidebar Assignees', () => {
});
});
it('renders a unassigned option', () => {
it('renders a unassigned option', async () => {
wrapper.setData({ isDropdownSearching: false });
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(wrapper.find(GlDeprecatedDropdownItem).text()).toBe('Unassigned');
});
});
it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', () => {
it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
wrapper.setData({ isDropdownSearching: false });
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
wrapper.find(SidebarAssignee).vm.$emit('update-alert-assignees', 'root');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
......@@ -123,7 +125,6 @@ describe('Alert Details Sidebar Assignees', () => {
},
});
});
});
it('emits an error when request contains error messages', () => {
wrapper.setData({ isDropdownSearching: false });
......@@ -151,7 +152,34 @@ describe('Alert Details Sidebar Assignees', () => {
it('stops updating and cancels loading when the request fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
wrapper.vm.updateAlertAssignees('root');
expect(wrapper.find('[data-testid="unassigned-users"]').text()).toBe('assign yourself');
expect(findUnassigned().text()).toBe('assign yourself');
});
it('shows a user avatar, username and full name when a user is set', () => {
mountComponent({
data: { alert: mockAlerts[1] },
sidebarCollapsed: false,
loading: false,
stubs: {
SidebarAssignee,
},
});
expect(
findAssigned()
.find('img')
.attributes('src'),
).toBe('/url');
expect(
findAssigned()
.find('.dropdown-menu-user-full-name')
.text(),
).toBe('root');
expect(
findAssigned()
.find('.dropdown-menu-user-username')
.text(),
).toBe('root');
});
});
});
......@@ -20,7 +20,7 @@
"startedAt": "2020-04-17T23:18:14.996Z",
"endedAt": "2020-04-17T23:18:14.996Z",
"status": "ACKNOWLEDGED",
"assignees": { "nodes": [{ "username": "root" }] },
"assignees": { "nodes": [{ "username": "root", "avatarUrl": "/url", "name": "root" }] },
"issueIid": "1",
"notes": {
"nodes": [
......@@ -49,7 +49,7 @@
"startedAt": "2020-04-17T23:18:14.996Z",
"endedAt": "2020-04-17T23:18:14.996Z",
"status": "RESOLVED",
"assignees": { "nodes": [{ "username": "root" }] },
"assignees": { "nodes": [{ "username": "root", "avatarUrl": "/url", "name": "root" }] },
"notes": {
"nodes": [
{
......
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