Commit feb6f8d8 authored by Illya Klymov's avatar Illya Klymov

Merge branch '223846-alert-assignee-avatar' into 'master'

Alert Management assignee avatar

Closes #223846

See merge request gitlab-org/gitlab!40275
parents 125fba58 82e56d7d
...@@ -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
...@@ -2252,9 +2252,6 @@ msgstr "" ...@@ -2252,9 +2252,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