Commit 6a15e1f7 authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Heinrich Lee Yu

Add alert assignee table, model, API support

Adds table and model for AlertManagement::AlertAssignee, which
will enable the association of users with alerts. This will
aide in triaging incoming alerts for a project.

This commit also adds the ability to read assignees for alerts
to the GraphQL API.
parent ef878663
...@@ -230,7 +230,7 @@ export default { ...@@ -230,7 +230,7 @@ export default {
:aria-label="__('Toggle sidebar')" :aria-label="__('Toggle sidebar')"
category="primary" category="primary"
variant="default" variant="default"
class="d-sm-none position-absolute toggle-sidebar-mobile-button" class="d-sm-none gl-absolute toggle-sidebar-mobile-button"
type="button" type="button"
@click="toggleSidebar" @click="toggleSidebar"
> >
......
<script> <script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SidebarHeader from './sidebar/sidebar_header.vue'; import SidebarHeader from './sidebar/sidebar_header.vue';
import SidebarTodo from './sidebar/sidebar_todo.vue'; import SidebarTodo from './sidebar/sidebar_todo.vue';
import SidebarStatus from './sidebar/sidebar_status.vue'; import SidebarStatus from './sidebar/sidebar_status.vue';
import SidebarAssignees from './sidebar/sidebar_assignees.vue';
export default { export default {
components: { components: {
SidebarAssignees,
SidebarHeader, SidebarHeader,
SidebarTodo, SidebarTodo,
SidebarStatus, SidebarStatus,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
sidebarCollapsed: { sidebarCollapsed: {
type: Boolean, type: Boolean,
...@@ -28,11 +32,6 @@ export default { ...@@ -28,11 +32,6 @@ export default {
return this.sidebarCollapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded'; return this.sidebarCollapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded';
}, },
}, },
methods: {
handleAlertSidebarError(errorMessage) {
this.$emit('alert-sidebar-error', errorMessage);
},
},
}; };
</script> </script>
...@@ -48,7 +47,14 @@ export default { ...@@ -48,7 +47,14 @@ export default {
:project-path="projectPath" :project-path="projectPath"
:alert="alert" :alert="alert"
@toggle-sidebar="$emit('toggle-sidebar')" @toggle-sidebar="$emit('toggle-sidebar')"
@alert-sidebar-error="handleAlertSidebarError" @alert-sidebar-error="$emit('alert-sidebar-error', $event)"
/>
<sidebar-assignees
v-if="glFeatures.alertAssignee"
:project-path="projectPath"
:alert="alert"
@toggle-sidebar="$emit('toggle-sidebar')"
@alert-sidebar-error="$emit('alert-sidebar-error', $event)"
/> />
<!-- TODO: Remove after adding extra attribute blocks to sidebar --> <!-- TODO: Remove after adding extra attribute blocks to sidebar -->
<div class="block"></div> <div class="block"></div>
......
<script>
import { GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
},
props: {
user: {
type: Object,
required: true,
},
active: {
type: Boolean,
required: true,
},
},
methods: {
isActive(name) {
return this.alert.assignees.nodes.some(({ username }) => username === name);
},
},
};
</script>
<template>
<gl-dropdown-item
:key="user.username"
data-testid="assigneeDropdownItem"
class="assignee-dropdown-item gl-vertical-align-middle"
:active="active"
active-class="is-active"
@click="$emit('update-alert-assignees', user.username)"
>
<span class="gl-relative mr-2">
<img
:alt="user.username"
:src="user.avatar_url"
:width="32"
class="avatar avatar-inline gl-m-0 s32"
data-qa-selector="avatar_image"
/>
</span>
<span class="d-flex gl-flex-direction-column gl-overflow-hidden">
<strong class="dropdown-menu-user-full-name">
{{ user.name }}
</strong>
<span class="dropdown-menu-user-username"> {{ user.username }}</span>
</span>
</gl-dropdown-item>
</template>
<script>
import {
GlIcon,
GlDropdown,
GlDropdownDivider,
GlDropdownHeader,
GlDropdownItem,
GlLoadingIcon,
GlTooltip,
GlButton,
GlSprintf,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.graphql';
import SidebarAssignee from './sidebar_assignee.vue';
import { debounce } from 'lodash';
const DATA_REFETCH_DELAY = 250;
export default {
FETCH_USERS_ERROR: s__(
'AlertManagement|There was an error while updating the assignee(s) list. Please try again.',
),
UPDATE_ALERT_ASSIGNEES_ERROR: s__(
'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.',
),
components: {
GlIcon,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlDropdownHeader,
GlLoadingIcon,
GlTooltip,
GlButton,
GlSprintf,
SidebarAssignee,
},
props: {
projectPath: {
type: String,
required: true,
},
alert: {
type: Object,
required: true,
},
isEditable: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
isDropdownShowing: false,
isDropdownSearching: false,
isUpdating: false,
search: '',
users: [],
};
},
computed: {
assignedUsers() {
return this.alert.assignees.nodes.length > 0
? this.alert.assignees.nodes[0].username
: s__('AlertManagement|Unassigned');
},
dropdownClass() {
return this.isDropdownShowing ? 'show' : 'gl-display-none';
},
userListValid() {
return !this.isDropdownSearching && this.users.length > 0;
},
userListEmpty() {
return !this.isDropdownSearching && this.users.length === 0;
},
},
watch: {
search: debounce(function debouncedUserSearch() {
this.updateAssigneesDropdown();
}, DATA_REFETCH_DELAY),
},
mounted() {
this.updateAssigneesDropdown();
},
methods: {
hideDropdown() {
this.isDropdownShowing = false;
},
toggleFormDropdown() {
this.isDropdownShowing = !this.isDropdownShowing;
const { dropdown } = this.$refs.dropdown.$refs;
if (dropdown && this.isDropdownShowing) {
dropdown.show();
}
},
isActive(name) {
return this.alert.assignees.nodes.some(({ username }) => username === name);
},
buildUrl(urlRoot, url) {
let newUrl;
if (urlRoot != null) {
newUrl = urlRoot.replace(/\/$/, '') + url;
}
return newUrl;
},
updateAssigneesDropdown() {
this.isDropdownSearching = true;
return axios
.get(this.buildUrl(gon.relative_url_root, '/autocomplete/users.json'), {
params: {
search: this.search,
per_page: 20,
active: true,
current_user: true,
project_id: gon.current_project_id,
},
})
.then(({ data }) => {
this.users = data;
})
.catch(() => {
this.$emit('alert-sidebar-error', this.$options.FETCH_USERS_ERROR);
})
.finally(() => {
this.isDropdownSearching = false;
});
},
updateAlertAssignees(assignees) {
this.isUpdating = true;
this.$apollo
.mutate({
mutation: alertSetAssignees,
variables: {
iid: this.alert.iid,
assigneeUsernames: [this.isActive(assignees) ? '' : assignees],
projectPath: this.projectPath,
},
})
.then(() => {
this.hideDropdown();
})
.catch(() => {
this.$emit('alert-sidebar-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR);
})
.finally(() => {
this.isUpdating = false;
});
},
},
};
</script>
<template>
<div class="block alert-status">
<div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
<gl-icon name="user" :size="14" />
<gl-loading-icon v-if="isUpdating" />
<p v-else class="collapse-truncated-title px-1">{{ assignedUsers }}</p>
</div>
<gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
<gl-sprintf :message="s__('AlertManagement|Alert assignee(s): %{assignees}')">
<template #assignees>
{{ assignedUsers }}
</template>
</gl-sprintf>
</gl-tooltip>
<div class="hide-collapsed">
<p class="title gl-display-flex gl-justify-content-space-between">
{{ s__('AlertManagement|Assignee') }}
<a
v-if="isEditable"
ref="editButton"
class="btn-link"
href="#"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
{{ s__('AlertManagement|Edit') }}
</a>
</p>
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
<gl-dropdown
ref="dropdown"
:text="assignedUsers"
class="w-100"
toggle-class="dropdown-menu-toggle"
variant="outline-default"
@keydown.esc.native="hideDropdown"
@hide="hideDropdown"
>
<div class="dropdown-title">
<span class="alert-title">{{ s__('AlertManagement|Assign Assignees') }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
class="dropdown-title-button dropdown-menu-close"
icon="close"
@click="hideDropdown"
/>
</div>
<div class="dropdown-input">
<input
v-model.trim="search"
class="dropdown-input-field"
type="search"
:placeholder="__('Search users')"
/>
<gl-icon name="search" class="dropdown-input-search ic-search" data-hidden="true" />
</div>
<div class="dropdown-content dropdown-body">
<template v-if="userListValid">
<gl-dropdown-item @click="updateAlertAssignees('')">
{{ s__('AlertManagement|Unassigned') }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-header class="mt-0">
{{ s__('AlertManagement|Assignee(s)') }}
</gl-dropdown-header>
<template v-for="user in users">
<sidebar-assignee
v-if="isActive(user.username)"
:key="user.username"
:user="user"
:active="true"
@update-alert-assignees="updateAlertAssignees"
/>
</template>
<gl-dropdown-divider />
<template v-for="user in users">
<sidebar-assignee
v-if="!isActive(user.username)"
:key="user.username"
:user="user"
:active="false"
@update-alert-assignees="updateAlertAssignees"
/>
</template>
</template>
<gl-dropdown-item v-else-if="userListEmpty">
{{ s__('AlertManagement|No Matching Results') }}
</gl-dropdown-item>
<gl-loading-icon v-else />
</div>
</gl-dropdown>
</div>
<gl-loading-icon v-if="isUpdating" :inline="true" />
<p
v-else-if="!isDropdownShowing"
class="value gl-m-0"
:class="{ 'no-value': !alert.assignees.nodes }"
>
<span v-if="alert.assignees.nodes" class="gl-text-gray-700" data-testid="assigned-users">{{
assignedUsers
}}</span>
<span v-else>
{{ s__('AlertManagement|None') }}
</span>
</p>
</div>
</div>
</template>
...@@ -17,13 +17,13 @@ export default { ...@@ -17,13 +17,13 @@ export default {
</script> </script>
<template> <template>
<div class="block"> <div class="block d-flex justify-content-between">
<span class="issuable-header-text hide-collapsed float-left"> <span class="issuable-header-text hide-collapsed">
{{ __('Quick actions') }} {{ __('Quick actions') }}
</span> </span>
<toggle-sidebar <toggle-sidebar
:collapsed="sidebarCollapsed" :collapsed="sidebarCollapsed"
css-classes="float-right" css-classes="ml-auto"
@toggle="$emit('toggle-sidebar')" @toggle="$emit('toggle-sidebar')"
/> />
<!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 --> <!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 -->
......
...@@ -51,7 +51,7 @@ export default { ...@@ -51,7 +51,7 @@ export default {
}, },
computed: { computed: {
dropdownClass() { dropdownClass() {
return this.isDropdownShowing ? 'show' : 'd-none'; return this.isDropdownShowing ? 'show' : 'gl-display-none';
}, },
}, },
methods: { methods: {
...@@ -81,7 +81,6 @@ export default { ...@@ -81,7 +81,6 @@ export default {
}) })
.then(() => { .then(() => {
this.trackStatusUpdate(status); this.trackStatusUpdate(status);
this.hideDropdown(); this.hideDropdown();
}) })
.catch(() => { .catch(() => {
...@@ -172,12 +171,15 @@ export default { ...@@ -172,12 +171,15 @@ export default {
<gl-loading-icon v-if="isUpdating" :inline="true" /> <gl-loading-icon v-if="isUpdating" :inline="true" />
<p <p
v-else-if="!isDropdownShowing" v-else-if="!isDropdownShowing"
class="value m-0" class="value gl-m-0"
:class="{ 'no-value': !$options.statuses[alert.status] }" :class="{ 'no-value': !$options.statuses[alert.status] }"
> >
<span v-if="$options.statuses[alert.status]" class="gl-text-gray-700">{{ <span
$options.statuses[alert.status] v-if="$options.statuses[alert.status]"
}}</span> class="gl-text-gray-700"
data-testid="status"
>{{ $options.statuses[alert.status] }}</span
>
<span v-else> <span v-else>
{{ s__('AlertManagement|None') }} {{ s__('AlertManagement|None') }}
</span> </span>
......
mutation($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) {
alertSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
) {
errors
alert {
iid
assignees {
nodes {
username
}
}
}
}
}
import axios from '~/lib/utils/axios_utils';
export default {
getAlertManagementList({ endpoint }) {
return axios.get(endpoint);
},
};
...@@ -49,4 +49,15 @@ ...@@ -49,4 +49,15 @@
background-color: $white; background-color: $white;
} }
} }
.assignee-dropdown-item {
button {
display: flex;
align-items: center;
&::before {
top: 50% !important;
}
}
}
} }
# frozen_string_literal: true
module Mutations
module AlertManagement
module Alerts
class SetAssignees < Base
graphql_name 'AlertSetAssignees'
argument :assignee_usernames,
[GraphQL::STRING_TYPE],
required: true,
description: 'The usernames to assign to the alert. Replaces existing assignees by default.'
argument :operation_mode,
Types::MutationOperationModeEnum,
required: false,
description: 'The operation to perform. Defaults to REPLACE.'
def resolve(args)
alert = authorized_find!(project_path: args[:project_path], iid: args[:iid])
result = set_assignees(alert, args[:assignee_usernames], args[:operation_mode])
prepare_response(result)
end
private
def set_assignees(alert, assignee_usernames, operation_mode)
operation_mode ||= Types::MutationOperationModeEnum.enum[:replace]
original_assignees = alert.assignees
target_users = find_target_users(assignee_usernames)
assignees = case Types::MutationOperationModeEnum.enum.key(operation_mode).to_sym
when :replace then target_users.uniq
when :append then (original_assignees + target_users).uniq
when :remove then (original_assignees - target_users)
end
::AlertManagement::Alerts::UpdateService.new(alert, current_user, assignees: assignees).execute
end
def find_target_users(assignee_usernames)
UsersFinder.new(current_user, username: assignee_usernames).execute
end
def prepare_response(result)
{
alert: result.payload[:alert],
errors: result.error? ? [result.message] : []
}
end
end
end
end
end
...@@ -9,6 +9,7 @@ module Types ...@@ -9,6 +9,7 @@ module Types
mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs
mount_mutation Mutations::AlertManagement::CreateAlertIssue mount_mutation Mutations::AlertManagement::CreateAlertIssue
mount_mutation Mutations::AlertManagement::UpdateAlertStatus mount_mutation Mutations::AlertManagement::UpdateAlertStatus
mount_mutation Mutations::AlertManagement::Alerts::SetAssignees
mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle mount_mutation Mutations::AwardEmojis::Toggle
......
# frozen_string_literal: true
module AlertManagement
module Alerts
class UpdateService
# @param alert [AlertManagement::Alert]
# @param current_user [User]
# @param params [Hash] Attributes of the alert
def initialize(alert, current_user, params)
@alert = alert
@current_user = current_user
@params = params
end
def execute
return error_no_permissions unless allowed?
return error_no_updates if params.empty?
filter_assignees
if alert.update(params)
success
else
error(alert.errors.full_messages.to_sentence)
end
end
private
attr_reader :alert, :current_user, :params
def allowed?
current_user.can?(:update_alert_management_alert, alert)
end
def filter_assignees
return if params[:assignees].nil?
# Take first assignee while multiple are not currently supported
params[:assignees] = Array(params[:assignees].first)
end
def success
ServiceResponse.success(payload: { alert: alert })
end
def error(message)
ServiceResponse.error(payload: { alert: alert }, message: message)
end
def error_no_permissions
error(_('You have no permissions'))
end
def error_no_updates
error(_('Please provide attributes to update'))
end
end
end
end
---
title: Allow the assignment of alerts to users from the alert detail view
merge_request: 33122
author:
type: added
...@@ -495,6 +495,61 @@ enum AlertManagementStatus { ...@@ -495,6 +495,61 @@ enum AlertManagementStatus {
TRIGGERED TRIGGERED
} }
"""
Autogenerated input type of AlertSetAssignees
"""
input AlertSetAssigneesInput {
"""
The usernames to assign to the alert. Replaces existing assignees by default.
"""
assigneeUsernames: [String!]!
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The iid of the alert to mutate
"""
iid: String!
"""
The operation to perform. Defaults to REPLACE.
"""
operationMode: MutationOperationMode
"""
The project the alert to mutate is in
"""
projectPath: ID!
}
"""
Autogenerated return type of AlertSetAssignees
"""
type AlertSetAssigneesPayload {
"""
The alert after mutation
"""
alert: AlertManagementAlert
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The issue created after mutation
"""
issue: Issue
}
""" """
An emoji awarded by a user. An emoji awarded by a user.
""" """
...@@ -7451,6 +7506,7 @@ type Mutation { ...@@ -7451,6 +7506,7 @@ type Mutation {
addAwardEmoji(input: AddAwardEmojiInput!): AddAwardEmojiPayload addAwardEmoji(input: AddAwardEmojiInput!): AddAwardEmojiPayload
addProjectToSecurityDashboard(input: AddProjectToSecurityDashboardInput!): AddProjectToSecurityDashboardPayload addProjectToSecurityDashboard(input: AddProjectToSecurityDashboardInput!): AddProjectToSecurityDashboardPayload
adminSidekiqQueuesDeleteJobs(input: AdminSidekiqQueuesDeleteJobsInput!): AdminSidekiqQueuesDeleteJobsPayload adminSidekiqQueuesDeleteJobs(input: AdminSidekiqQueuesDeleteJobsInput!): AdminSidekiqQueuesDeleteJobsPayload
alertSetAssignees(input: AlertSetAssigneesInput!): AlertSetAssigneesPayload
boardListUpdateLimitMetrics(input: BoardListUpdateLimitMetricsInput!): BoardListUpdateLimitMetricsPayload boardListUpdateLimitMetrics(input: BoardListUpdateLimitMetricsInput!): BoardListUpdateLimitMetricsPayload
commitCreate(input: CommitCreateInput!): CommitCreatePayload commitCreate(input: CommitCreateInput!): CommitCreatePayload
createAlertIssue(input: CreateAlertIssueInput!): CreateAlertIssuePayload createAlertIssue(input: CreateAlertIssueInput!): CreateAlertIssuePayload
......
...@@ -1175,6 +1175,168 @@ ...@@ -1175,6 +1175,168 @@
], ],
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "AlertSetAssigneesInput",
"description": "Autogenerated input type of AlertSetAssignees",
"fields": null,
"inputFields": [
{
"name": "projectPath",
"description": "The project the alert to mutate is in",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "iid",
"description": "The iid of the alert to mutate",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "assigneeUsernames",
"description": "The usernames to assign to the alert. Replaces existing assignees by default.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"defaultValue": null
},
{
"name": "operationMode",
"description": "The operation to perform. Defaults to REPLACE.",
"type": {
"kind": "ENUM",
"name": "MutationOperationMode",
"ofType": null
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AlertSetAssigneesPayload",
"description": "Autogenerated return type of AlertSetAssignees",
"fields": [
{
"name": "alert",
"description": "The alert after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "AlertManagementAlert",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issue",
"description": "The issue created after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Issue",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "AwardEmoji", "name": "AwardEmoji",
...@@ -20963,6 +21125,33 @@ ...@@ -20963,6 +21125,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "alertSetAssignees",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "AlertSetAssigneesInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "AlertSetAssigneesPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "boardListUpdateLimitMetrics", "name": "boardListUpdateLimitMetrics",
"description": null, "description": null,
...@@ -90,6 +90,17 @@ Represents total number of alerts for the represented categories ...@@ -90,6 +90,17 @@ Represents total number of alerts for the represented categories
| `resolved` | Int | Number of alerts with status RESOLVED for the project | | `resolved` | Int | Number of alerts with status RESOLVED for the project |
| `triggered` | Int | Number of alerts with status TRIGGERED for the project | | `triggered` | Int | Number of alerts with status TRIGGERED for the project |
## AlertSetAssigneesPayload
Autogenerated return type of AlertSetAssignees
| Name | Type | Description |
| --- | ---- | ---------- |
| `alert` | AlertManagementAlert | The alert after mutation |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue created after mutation |
## AwardEmoji ## AwardEmoji
An emoji awarded by a user. An emoji awarded by a user.
......
...@@ -1846,6 +1846,9 @@ msgstr "" ...@@ -1846,6 +1846,9 @@ msgstr ""
msgid "AlertManagement|Alert" msgid "AlertManagement|Alert"
msgstr "" msgstr ""
msgid "AlertManagement|Alert assignee(s): %{assignees}"
msgstr ""
msgid "AlertManagement|Alert detail" msgid "AlertManagement|Alert detail"
msgstr "" msgstr ""
...@@ -1861,9 +1864,18 @@ msgstr "" ...@@ -1861,9 +1864,18 @@ msgstr ""
msgid "AlertManagement|All alerts" msgid "AlertManagement|All alerts"
msgstr "" msgstr ""
msgid "AlertManagement|Assign Assignees"
msgstr ""
msgid "AlertManagement|Assign status" msgid "AlertManagement|Assign status"
msgstr "" msgstr ""
msgid "AlertManagement|Assignee"
msgstr ""
msgid "AlertManagement|Assignee(s)"
msgstr ""
msgid "AlertManagement|Assignees" msgid "AlertManagement|Assignees"
msgstr "" msgstr ""
...@@ -1903,6 +1915,9 @@ msgstr "" ...@@ -1903,6 +1915,9 @@ msgstr ""
msgid "AlertManagement|More information" msgid "AlertManagement|More information"
msgstr "" msgstr ""
msgid "AlertManagement|No Matching Results"
msgstr ""
msgid "AlertManagement|No alert data to display." msgid "AlertManagement|No alert data to display."
msgstr "" msgstr ""
...@@ -1951,6 +1966,12 @@ msgstr "" ...@@ -1951,6 +1966,12 @@ msgstr ""
msgid "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear." msgid "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear."
msgstr "" msgstr ""
msgid "AlertManagement|There was an error while updating the assignee(s) list. Please try again."
msgstr ""
msgid "AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again."
msgstr ""
msgid "AlertManagement|There was an error while updating the status of the alert. Please try again." msgid "AlertManagement|There was an error while updating the status of the alert. Please try again."
msgstr "" msgstr ""
...@@ -16318,6 +16339,9 @@ msgstr "" ...@@ -16318,6 +16339,9 @@ msgstr ""
msgid "Please provide a valid email address." msgid "Please provide a valid email address."
msgstr "" msgstr ""
msgid "Please provide attributes to update"
msgstr ""
msgid "Please refer to <a href=\"%{docs_url}\">%{docs_url}</a>" msgid "Please refer to <a href=\"%{docs_url}\">%{docs_url}</a>"
msgstr "" msgstr ""
......
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import AlertDetails from '~/alert_management/components/alert_details.vue'; import AlertDetails from '~/alert_management/components/alert_details.vue';
import createIssueQuery from '~/alert_management/graphql/mutations/create_issue_from_alert.graphql'; import createIssueQuery from '~/alert_management/graphql/mutations/create_issue_from_alert.graphql';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths } from '~/lib/utils/url_utility';
...@@ -14,6 +16,7 @@ const mockAlert = mockAlerts[0]; ...@@ -14,6 +16,7 @@ const mockAlert = mockAlerts[0];
describe('AlertDetails', () => { describe('AlertDetails', () => {
let wrapper; let wrapper;
let mock;
const projectPath = 'root/alerts'; const projectPath = 'root/alerts';
const projectIssuesPath = 'root/alerts/-/issues'; const projectIssuesPath = 'root/alerts/-/issues';
...@@ -43,12 +46,17 @@ describe('AlertDetails', () => { ...@@ -43,12 +46,17 @@ describe('AlertDetails', () => {
}); });
} }
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => { afterEach(() => {
if (wrapper) { if (wrapper) {
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
} }
} }
mock.restore();
}); });
const findCreateIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]'); const findCreateIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]');
......
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { GlDropdownItem } from '@gitlab/ui';
import SidebarAssignee from '~/alert_management/components/sidebar/sidebar_assignee.vue';
import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue';
import AlertSetAssignees from '~/alert_management/graphql/mutations/alert_set_assignees.graphql';
import mockAlerts from '../mocks/alerts.json';
const mockAlert = mockAlerts[0];
describe('Alert Details Sidebar Assignees', () => {
let wrapper;
let mock;
function mountComponent({
data,
users = [],
isDropdownSearching = false,
sidebarCollapsed = true,
loading = false,
stubs = {},
} = {}) {
wrapper = shallowMount(SidebarAssignees, {
data() {
return {
users,
isDropdownSearching,
};
},
propsData: {
alert: { ...mockAlert },
...data,
sidebarCollapsed,
projectPath: 'projectPath',
},
mocks: {
$apollo: {
mutate: jest.fn(),
queries: {
alert: {
loading,
},
},
},
},
stubs,
});
}
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
mock.restore();
});
describe('updating the alert status', () => {
const mockUpdatedMutationResult = {
data: {
updateAlertStatus: {
errors: [],
alert: {
assigneeUsernames: ['root'],
},
},
},
};
beforeEach(() => {
mock = new MockAdapter(axios);
const path = '/autocomplete/users.json';
const users = [
{
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
id: 1,
name: 'User 1',
username: 'root',
},
{
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
id: 2,
name: 'User 2',
username: 'not-root',
},
];
mock.onGet(path).replyOnce(200, users);
mountComponent({
data: { alert: mockAlert },
sidebarCollapsed: false,
loading: false,
users,
stubs: {
SidebarAssignee,
},
});
});
it('renders a unassigned option', () => {
wrapper.setData({ isDropdownSearching: false });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlDropdownItem).text()).toBe('Unassigned');
});
});
it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
wrapper.setData({ isDropdownSearching: false });
return wrapper.vm.$nextTick().then(() => {
wrapper.find(SidebarAssignee).vm.$emit('update-alert-assignees', 'root');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: AlertSetAssignees,
variables: {
iid: '1527542',
assigneeUsernames: ['root'],
projectPath: 'projectPath',
},
});
});
});
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="assigned-users"]').text()).toBe('Unassigned');
});
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount, mount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import AlertSidebar from '~/alert_management/components/alert_sidebar.vue'; import AlertSidebar from '~/alert_management/components/alert_sidebar.vue';
import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue';
import mockAlerts from '../mocks/alerts.json';
const mockAlert = mockAlerts[0];
describe('Alert Details Sidebar', () => { describe('Alert Details Sidebar', () => {
let wrapper; let wrapper;
let mock;
function mountComponent({ function mountComponent({
sidebarCollapsed = true, sidebarCollapsed = true,
mountMethod = shallowMount, mountMethod = shallowMount,
alertAssignee = false,
stubs = {}, stubs = {},
alert = {},
} = {}) { } = {}) {
wrapper = mountMethod(AlertSidebar, { wrapper = mountMethod(AlertSidebar, {
propsData: { propsData: {
alert: {}, alert,
sidebarCollapsed, sidebarCollapsed,
projectPath: 'projectPath', projectPath: 'projectPath',
}, },
provide: {
glFeatures: { alertAssignee },
},
stubs, stubs,
}); });
} }
...@@ -23,15 +35,30 @@ describe('Alert Details Sidebar', () => { ...@@ -23,15 +35,30 @@ describe('Alert Details Sidebar', () => {
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
} }
mock.restore();
}); });
describe('the sidebar renders', () => { describe('the sidebar renders', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios);
mountComponent(); mountComponent();
}); });
it('open as default', () => { it('open as default', () => {
expect(wrapper.props('sidebarCollapsed')).toBe(true); expect(wrapper.props('sidebarCollapsed')).toBe(true);
}); });
it('should not render side bar assignee dropdown by default', () => {
expect(wrapper.find(SidebarAssignees).exists()).toBe(false);
});
it('should render side bar assignee dropdown if feature flag enabled', () => {
mountComponent({
mountMethod: mount,
alertAssignee: true,
alert: mockAlert,
});
expect(wrapper.find(SidebarAssignees).exists()).toBe(true);
});
}); });
}); });
...@@ -13,14 +13,8 @@ describe('Alert Details Sidebar Status', () => { ...@@ -13,14 +13,8 @@ describe('Alert Details Sidebar Status', () => {
const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); const findStatusDropdownItem = () => wrapper.find(GlDropdownItem);
const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon); const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon);
function mountComponent({ function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) {
data, wrapper = shallowMount(AlertSidebarStatus, {
sidebarCollapsed = true,
loading = false,
mountMethod = shallowMount,
stubs = {},
} = {}) {
wrapper = mountMethod(AlertSidebarStatus, {
propsData: { propsData: {
alert: { ...mockAlert }, alert: { ...mockAlert },
...data, ...data,
...@@ -85,7 +79,7 @@ describe('Alert Details Sidebar Status', () => { ...@@ -85,7 +79,7 @@ describe('Alert Details Sidebar Status', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
findStatusDropdownItem().vm.$emit('click'); findStatusDropdownItem().vm.$emit('click');
expect(findStatusLoadingIcon().exists()).toBe(false); expect(findStatusLoadingIcon().exists()).toBe(false);
expect(wrapper.find('.gl-text-gray-700').text()).toBe('Triggered'); expect(wrapper.find('[data-testid="status"]').text()).toBe('Triggered');
}); });
}); });
......
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::AlertManagement::Alerts::SetAssignees do
let_it_be(:starting_assignee) { create(:user) }
let_it_be(:unassigned_user) { create(:user) }
let_it_be(:alert) { create(:alert_management_alert, assignees: [starting_assignee]) }
let_it_be(:project) { alert.project }
let(:current_user) { starting_assignee }
let(:assignee_usernames) { [unassigned_user.username] }
let(:operation_mode) { nil }
let(:args) do
{
project_path: project.full_path,
iid: alert.iid,
assignee_usernames: assignee_usernames,
operation_mode: operation_mode
}
end
before_all do
project.add_developer(starting_assignee)
project.add_developer(unassigned_user)
end
specify { expect(described_class).to require_graphql_authorizations(:update_alert_management_alert) }
describe '#resolve' do
let(:expected_assignees) { [unassigned_user] }
subject(:resolve) { mutation_for(project, current_user).resolve(args) }
shared_examples 'successful resolution' do
after do
alert.assignees = [starting_assignee]
end
it 'successfully resolves' do
expect(resolve).to eq(alert: alert.reload, errors: [])
expect(alert.assignees).to eq(expected_assignees)
end
end
shared_examples 'noop' do
it 'makes no changes' do
original_assignees = alert.assignees
expect(resolve).to eq(alert: alert.reload, errors: [])
expect(alert.assignees).to eq(original_assignees)
end
end
context 'when operation mode is not specified' do
it_behaves_like 'successful resolution'
end
context 'when user does not have permission to update alerts' do
let(:current_user) { create(:user) }
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'for APPEND operation' do
let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] }
# Only allow a single assignee
context 'when a different user is already assigned' do
it_behaves_like 'noop'
end
context 'when no users are specified' do
let(:assignee_usernames) { [] }
it_behaves_like 'noop'
end
context 'when a user is specified and no user is assigned' do
before do
alert.assignees = []
end
it_behaves_like 'successful resolution'
end
context 'when the specified user is already assigned to the alert' do
let(:assignee_usernames) { [starting_assignee.username] }
it_behaves_like 'noop'
end
end
context 'for REPLACE operation' do
let(:operation_mode) { Types::MutationOperationModeEnum.enum[:replace] }
context 'when a different user is already assigned' do
it_behaves_like 'successful resolution'
end
context 'when no users are specified' do
let(:assignee_usernames) { [] }
let(:expected_assignees) { [] }
it_behaves_like 'successful resolution'
end
context 'when a user is specified and no user is assigned' do
before do
alert.assignees = []
end
it_behaves_like 'successful resolution'
end
context 'when the specified user is already assigned to the alert' do
let(:assignee_usernames) { [starting_assignee.username] }
it_behaves_like 'noop'
end
context 'when multiple users are specified' do
let(:assignees) { [starting_assignee, unassigned_user] }
let(:assignee_usernames) { assignees.map(&:username) }
let(:expected_assignees) { [assignees.last] }
it_behaves_like 'successful resolution'
end
end
context 'for REMOVE operation' do
let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] }
context 'when a different user is already assigned' do
it_behaves_like 'noop'
end
context 'when no users are specified' do
let(:assignee_usernames) { [] }
it_behaves_like 'noop'
end
context 'when a user is specified and no user is assigned' do
before do
alert.assignees = []
end
it_behaves_like 'noop'
end
context 'when the specified user is already assigned to the alert' do
let(:assignee_usernames) { [starting_assignee.username] }
let(:expected_assignees) { [] }
it_behaves_like 'successful resolution'
end
end
end
def mutation_for(project, user)
described_class.new(object: project, context: { current_user: user }, field: nil)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Setting assignees of an alert' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let_it_be(:alert) { create(:alert_management_alert, project: project) }
let(:input) { { assignee_usernames: [current_user.username] } }
let(:mutation) do
graphql_mutation(
:alert_set_assignees,
{ project_path: project.full_path, iid: alert.iid.to_s }.merge(input),
<<~QL
clientMutationId
errors
alert {
assignees {
nodes {
username
}
}
}
QL
)
end
let(:mutation_response) { graphql_mutation_response(:alert_set_assignees) }
before_all do
project.add_developer(current_user)
end
it 'updates the assignee of the alert' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['alert']['assignees']['nodes'].first['username']).to eq(current_user.username)
expect(alert.reload.assignees).to contain_exactly(current_user)
end
context 'with operation_mode specified' do
let(:input) do
{
assignee_usernames: [current_user.username],
operation_mode: Types::MutationOperationModeEnum.enum[:remove]
}
end
before do
alert.assignees = [current_user]
end
it 'updates the assignee of the alert' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['alert']['assignees']['nodes']).to be_empty
expect(alert.reload.assignees).to be_empty
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe AlertManagement::Alerts::UpdateService do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be(:alert, reload: true) { create(:alert_management_alert) }
let_it_be(:project) { alert.project }
let(:current_user) { user_with_permissions }
let(:params) { {} }
let(:service) { described_class.new(alert, current_user, params) }
before_all do
project.add_developer(user_with_permissions)
end
describe '#execute' do
subject(:response) { service.execute }
context 'when user does not have permission to update alerts' do
let(:current_user) { user_without_permissions }
it 'results in an error' do
expect(response).to be_error
expect(response.message).to eq('You have no permissions')
end
end
context 'when no parameters are included' do
it 'results in an error' do
expect(response).to be_error
expect(response.message).to eq('Please provide attributes to update')
end
end
context 'when an error occures during update' do
let(:params) { { title: nil } }
it 'results in an error' do
expect(response).to be_error
expect(response.message).to eq("Title can't be blank")
end
end
context 'when a model attribute is included' do
let(:params) { { title: 'This is an updated alert.' } }
it 'updates the attribute' do
original_title = alert.title
expect { response }.to change { alert.title }.from(original_title).to(params[:title])
expect(response).to be_success
end
end
context 'when assignees are included' do
let(:params) { { assignees: [user_with_permissions] } }
after do
alert.assignees = []
end
it 'assigns the user' do
expect { response }.to change { alert.reload.assignees }.from([]).to(params[:assignees])
expect(response).to be_success
end
context 'with multiple users included' do
let(:params) { { assignees: [user_with_permissions, user_without_permissions] } }
it 'assigns the first permissioned user' do
expect { response }.to change { alert.reload.assignees }.from([]).to([user_with_permissions])
expect(response).to be_success
end
end
end
end
end
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