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