diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/alert_details/components')
11 files changed, 1372 insertions, 0 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue new file mode 100644 index 00000000000..bcea7ca654e --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -0,0 +1,392 @@ +<script> +import { + GlAlert, + GlBadge, + GlIcon, + GlLink, + GlLoadingIcon, + GlSprintf, + GlTabs, + GlTab, + GlButton, + GlSafeHtmlDirective, +} from '@gitlab/ui'; +import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import { fetchPolicies } from '~/lib/graphql'; +import { toggleContainerClasses } from '~/lib/utils/dom_utils'; +import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; +import * as Sentry from '~/sentry/wrapper'; +import Tracking from '~/tracking'; +import initUserPopovers from '~/user_popovers'; +import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { SEVERITY_LEVELS } from '../constants'; +import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql'; +import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql'; +import alertQuery from '../graphql/queries/alert_details.query.graphql'; +import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql'; +import AlertMetrics from './alert_metrics.vue'; +import AlertSidebar from './alert_sidebar.vue'; +import AlertSummaryRow from './alert_summary_row.vue'; +import SystemNote from './system_notes/system_note.vue'; + +const containerEl = document.querySelector('.page-with-contextual-sidebar'); + +export default { + i18n: { + errorMsg: s__( + 'AlertManagement|There was an error displaying the alert. Please refresh the page to try again.', + ), + reportedAt: s__('AlertManagement|Reported %{when}'), + reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + severityLabels: SEVERITY_LEVELS, + tabsConfig: [ + { + id: 'overview', + title: s__('AlertManagement|Alert details'), + }, + { + id: 'metrics', + title: s__('AlertManagement|Metrics'), + }, + { + id: 'activity', + title: s__('AlertManagement|Activity feed'), + }, + ], + components: { + AlertDetailsTable, + AlertSummaryRow, + GlBadge, + GlAlert, + GlIcon, + GlLink, + GlLoadingIcon, + GlSprintf, + GlTab, + GlTabs, + GlButton, + TimeAgoTooltip, + AlertSidebar, + SystemNote, + AlertMetrics, + }, + inject: { + projectPath: { + default: '', + }, + alertId: { + default: '', + }, + isThreatMonitoringPage: { + default: false, + }, + projectId: { + default: '', + }, + projectIssuesPath: { + default: '', + }, + trackAlertsDetailsViewsOptions: { + default: null, + }, + }, + apollo: { + alert: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: alertQuery, + variables() { + return { + fullPath: this.projectPath, + alertId: this.alertId, + }; + }, + update(data) { + return data?.project?.alertManagementAlerts?.nodes?.[0] ?? null; + }, + error(error) { + this.errored = true; + Sentry.captureException(error); + }, + }, + sidebarStatus: { + query: sidebarStatusQuery, + }, + }, + data() { + return { + alert: null, + errored: false, + sidebarStatus: false, + isErrorDismissed: false, + createIncidentError: '', + incidentCreationInProgress: false, + sidebarErrorMessage: '', + }; + }, + computed: { + loading() { + return this.$apollo.queries.alert.loading; + }, + reportedAtMessage() { + return this.alert?.monitoringTool + ? this.$options.i18n.reportedAtWithTool + : this.$options.i18n.reportedAt; + }, + showErrorMsg() { + return this.errored && !this.isErrorDismissed; + }, + activeTab() { + return this.$route.params.tabId || this.$options.tabsConfig[0].id; + }, + currentTabIndex: { + get() { + return this.$options.tabsConfig.findIndex((tab) => tab.id === this.activeTab); + }, + set(tabIdx) { + const tabId = this.$options.tabsConfig[tabIdx].id; + this.$router.replace({ name: 'tab', params: { tabId } }); + }, + }, + environmentName() { + return this.alert?.environment?.name; + }, + environmentPath() { + return this.alert?.environment?.path; + }, + }, + mounted() { + if (this.trackAlertsDetailsViewsOptions) { + this.trackPageViews(); + } + toggleContainerClasses(containerEl, { + 'issuable-bulk-update-sidebar': true, + 'right-sidebar-expanded': true, + }); + }, + updated() { + this.$nextTick(() => { + highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')); + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }); + }, + methods: { + dismissError() { + this.isErrorDismissed = true; + this.sidebarErrorMessage = ''; + }, + toggleSidebar() { + this.$apollo.mutate({ mutation: toggleSidebarStatusMutation }); + toggleContainerClasses(containerEl, { + 'right-sidebar-collapsed': !this.sidebarStatus, + 'right-sidebar-expanded': this.sidebarStatus, + }); + }, + handleAlertSidebarError(errorMessage) { + this.errored = true; + this.sidebarErrorMessage = errorMessage; + }, + createIncident() { + this.incidentCreationInProgress = true; + + this.$apollo + .mutate({ + mutation: createIssueMutation, + variables: { + iid: this.alert.iid, + projectPath: this.projectPath, + }, + }) + .then( + ({ + data: { + createAlertIssue: { errors, issue }, + }, + }) => { + if (errors?.length) { + [this.createIncidentError] = errors; + this.incidentCreationInProgress = false; + } else if (issue) { + visitUrl(this.incidentPath(issue.iid)); + } + }, + ) + .catch((error) => { + this.createIncidentError = error; + this.incidentCreationInProgress = false; + }); + }, + incidentPath(issueId) { + return joinPaths(this.projectIssuesPath, issueId); + }, + trackPageViews() { + const { category, action } = this.trackAlertsDetailsViewsOptions; + Tracking.event(category, action); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> + <p v-safe-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> + </gl-alert> + <gl-alert + v-if="createIncidentError" + variant="danger" + data-testid="incidentCreationError" + @dismiss="createIncidentError = null" + > + {{ createIncidentError }} + </gl-alert> + <div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div> + <div + v-if="alert" + class="alert-management-details gl-relative" + :class="{ 'pr-sm-8': sidebarStatus }" + > + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-flex-direction-column gl-sm-flex-direction-row" + > + <div data-testid="alert-header"> + <gl-badge class="gl-mr-3"> + <strong>{{ s__('AlertManagement|Alert') }}</strong> + </gl-badge> + <span> + <gl-sprintf :message="reportedAtMessage"> + <template #when> + <time-ago-tooltip :time="alert.createdAt" /> + </template> + <template #tool>{{ alert.monitoringTool }}</template> + </gl-sprintf> + </span> + </div> + <gl-button + v-if="alert.issueIid" + class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-incident-button" + data-testid="viewIncidentBtn" + :href="incidentPath(alert.issueIid)" + category="primary" + variant="success" + > + {{ s__('AlertManagement|View incident') }} + </gl-button> + <gl-button + v-else + class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-incident-button" + data-testid="createIncidentBtn" + :loading="incidentCreationInProgress" + category="primary" + variant="success" + @click="createIncident()" + > + {{ s__('AlertManagement|Create incident') }} + </gl-button> + <gl-button + :aria-label="__('Toggle sidebar')" + category="primary" + variant="default" + class="d-sm-none gl-absolute toggle-sidebar-mobile-button" + type="button" + icon="chevron-double-lg-left" + @click="toggleSidebar" + /> + </div> + <div + v-if="alert" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center" + > + <h2 data-testid="title">{{ alert.title }}</h2> + </div> + <gl-tabs v-if="alert" v-model="currentTabIndex" data-testid="alertDetailsTabs"> + <gl-tab :data-testid="$options.tabsConfig[0].id" :title="$options.tabsConfig[0].title"> + <alert-summary-row v-if="alert.severity" :label="`${s__('AlertManagement|Severity')}:`"> + <span data-testid="severity"> + <gl-icon + class="gl-vertical-align-middle" + :size="12" + :name="`severity-${alert.severity.toLowerCase()}`" + :class="`icon-${alert.severity.toLowerCase()}`" + /> + {{ $options.severityLabels[alert.severity] }} + </span> + </alert-summary-row> + <alert-summary-row + v-if="environmentName" + :label="`${s__('AlertManagement|Environment')}:`" + > + <gl-link + v-if="environmentPath" + class="gl-display-inline-block" + data-testid="environmentPath" + :href="environmentPath" + > + {{ environmentName }} + </gl-link> + <span v-else data-testid="environmentName">{{ environmentName }}</span> + </alert-summary-row> + <alert-summary-row + v-if="alert.startedAt" + :label="`${s__('AlertManagement|Start time')}:`" + > + <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> + </alert-summary-row> + <alert-summary-row + v-if="alert.eventCount" + :label="`${s__('AlertManagement|Events')}:`" + data-testid="eventCount" + > + {{ alert.eventCount }} + </alert-summary-row> + <alert-summary-row + v-if="alert.monitoringTool" + :label="`${s__('AlertManagement|Tool')}:`" + data-testid="monitoringTool" + > + {{ alert.monitoringTool }} + </alert-summary-row> + <alert-summary-row + v-if="alert.service" + :label="`${s__('AlertManagement|Service')}:`" + data-testid="service" + > + {{ alert.service }} + </alert-summary-row> + <alert-summary-row + v-if="alert.runbook" + :label="`${s__('AlertManagement|Runbook')}:`" + data-testid="runbook" + > + {{ alert.runbook }} + </alert-summary-row> + <alert-details-table :alert="alert" :loading="loading" /> + </gl-tab> + <gl-tab + v-if="isThreatMonitoringPage" + :data-testid="$options.tabsConfig[1].id" + :title="$options.tabsConfig[1].title" + > + <alert-metrics :dashboard-url="alert.metricsDashboardUrl" /> + </gl-tab> + <gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title"> + <div v-if="alert.notes.nodes.length > 0" class="issuable-discussion"> + <ul class="notes main-notes-list timeline"> + <system-note v-for="note in alert.notes.nodes" :key="note.id" :note="note" /> + </ul> + </div> + </gl-tab> + </gl-tabs> + <alert-sidebar + :alert="alert" + @toggle-sidebar="toggleSidebar" + @alert-error="handleAlertSidebarError" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue new file mode 100644 index 00000000000..dd4faa03c00 --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue @@ -0,0 +1,56 @@ +<script> +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as Sentry from '~/sentry/wrapper'; + +Vue.use(Vuex); + +export default { + props: { + dashboardUrl: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + metricEmbedComponent: null, + namespace: 'alertMetrics', + }; + }, + mounted() { + if (this.dashboardUrl) { + Promise.all([ + import('~/monitoring/components/embeds/metric_embed.vue'), + import('~/monitoring/stores'), + ]) + .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => { + this.$store = new Vuex.Store({ + modules: { + [this.namespace]: monitoringDashboard, + }, + }); + this.metricEmbedComponent = MetricEmbed; + }) + .catch((e) => Sentry.captureException(e)); + } + }, +}; +</script> + +<template> + <div class="gl-py-3"> + <div v-if="dashboardUrl" ref="metricsChart"> + <component + :is="metricEmbedComponent" + v-if="metricEmbedComponent" + :dashboard-url="dashboardUrl" + :namespace="namespace" + /> + </div> + <div v-else ref="emptyState"> + {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue new file mode 100644 index 00000000000..a01bd462196 --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue @@ -0,0 +1,86 @@ +<script> +import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql'; +import SidebarAssignees from './sidebar/sidebar_assignees.vue'; +import SidebarHeader from './sidebar/sidebar_header.vue'; +import SidebarStatus from './sidebar/sidebar_status.vue'; +import SidebarTodo from './sidebar/sidebar_todo.vue'; + +export default { + components: { + SidebarAssignees, + SidebarHeader, + SidebarTodo, + SidebarStatus, + }, + inject: { + projectPath: { + default: '', + }, + projectId: { + default: '', + }, + // TODO remove this limitation in https://gitlab.com/gitlab-org/gitlab/-/issues/296717 + isThreatMonitoringPage: { + default: false, + }, + }, + props: { + alert: { + type: Object, + required: true, + }, + }, + apollo: { + sidebarStatus: { + query: sidebarStatusQuery, + }, + }, + data() { + return { + sidebarStatus: false, + }; + }, + computed: { + sidebarCollapsedClass() { + return this.sidebarStatus ? 'right-sidebar-collapsed' : 'right-sidebar-expanded'; + }, + }, +}; +</script> + +<template> + <aside :class="sidebarCollapsedClass" class="right-sidebar alert-sidebar"> + <div class="issuable-sidebar js-issuable-update"> + <sidebar-header + :sidebar-collapsed="sidebarStatus" + :project-path="projectPath" + :alert="alert" + @toggle-sidebar="$emit('toggle-sidebar')" + @alert-error="$emit('alert-error', $event)" + /> + <sidebar-todo + v-if="sidebarStatus" + :project-path="projectPath" + :alert="alert" + :sidebar-collapsed="sidebarStatus" + @alert-error="$emit('alert-error', $event)" + /> + <sidebar-status + v-if="!isThreatMonitoringPage" + :project-path="projectPath" + :alert="alert" + @toggle-sidebar="$emit('toggle-sidebar')" + @alert-error="$emit('alert-error', $event)" + /> + <sidebar-assignees + :project-path="projectPath" + :project-id="projectId" + :alert="alert" + :sidebar-collapsed="sidebarStatus" + @toggle-sidebar="$emit('toggle-sidebar')" + @alert-error="$emit('alert-error', $event)" + /> + <div class="block"></div> + </div> + </aside> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue new file mode 100644 index 00000000000..8d5eb24ed1d --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue @@ -0,0 +1,125 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; + +export default { + i18n: { + UPDATE_ALERT_STATUS_ERROR: s__( + 'AlertManagement|There was an error while updating the status of the alert.', + ), + UPDATE_ALERT_STATUS_INSTRUCTION: s__('AlertManagement|Please try again.'), + }, + statuses: { + TRIGGERED: s__('AlertManagement|Triggered'), + ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), + RESOLVED: s__('AlertManagement|Resolved'), + }, + components: { + GlDropdown, + GlDropdownItem, + }, + inject: { + trackAlertStatusUpdateOptions: { + default: null, + }, + }, + props: { + projectPath: { + type: String, + required: true, + }, + alert: { + type: Object, + required: true, + }, + isDropdownShowing: { + type: Boolean, + required: false, + }, + isSidebar: { + type: Boolean, + required: true, + }, + }, + computed: { + dropdownClass() { + // eslint-disable-next-line no-nested-ternary + return this.isSidebar ? (this.isDropdownShowing ? 'show' : 'gl-display-none') : ''; + }, + }, + methods: { + updateAlertStatus(status) { + this.$emit('handle-updating', true); + this.$apollo + .mutate({ + mutation: updateAlertStatusMutation, + variables: { + iid: this.alert.iid, + status: status.toUpperCase(), + projectPath: this.projectPath, + }, + }) + .then((resp) => { + if (this.trackAlertStatusUpdateOptions) { + this.trackStatusUpdate(status); + } + const errors = resp.data?.updateAlertStatus?.errors || []; + + if (errors[0]) { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`, + ); + } + + this.$emit('hide-dropdown'); + }) + .catch(() => { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${this.$options.i18n.UPDATE_ALERT_STATUS_INSTRUCTION}`, + ); + }) + .finally(() => { + this.$emit('handle-updating', false); + }); + }, + trackStatusUpdate(status) { + const { category, action, label } = this.trackAlertStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, + }, +}; +</script> + +<template> + <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> + <gl-dropdown + ref="dropdown" + right + :text="$options.statuses[alert.status]" + class="w-100" + toggle-class="dropdown-menu-toggle" + @keydown.esc.native="$emit('hide-dropdown')" + @hide="$emit('hide-dropdown')" + > + <p v-if="isSidebar" class="gl-new-dropdown-header-top" data-testid="dropdown-header"> + {{ s__('AlertManagement|Assign status') }} + </p> + <div class="dropdown-content dropdown-body"> + <gl-dropdown-item + v-for="(label, field) in $options.statuses" + :key="field" + data-testid="statusDropdownItem" + :active="label.toUpperCase() === alert.status" + :active-class="'is-active'" + @click="updateAlertStatus(label)" + > + {{ label }} + </gl-dropdown-item> + </div> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_summary_row.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_summary_row.vue new file mode 100644 index 00000000000..13835b7e2fa --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_summary_row.vue @@ -0,0 +1,18 @@ +<script> +export default { + props: { + label: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="gl-my-5 gl-display-flex"> + <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">{{ label }}</div> + <div class="gl-pl-2"> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue new file mode 100644 index 00000000000..c39a72a45b9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue @@ -0,0 +1,38 @@ +<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" + :active="active" + active-class="is-active" + :avatar-url="user.avatar_url" + :secondary-text="`@${user.username}`" + @click="$emit('update-alert-assignees', user.username)" + > + {{ user.name }} + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue new file mode 100644 index 00000000000..2a999b908f9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -0,0 +1,299 @@ +<script> +import { + GlIcon, + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + GlTooltip, + GlButton, + GlSprintf, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { s__, __ } from '~/locale'; +import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql'; +import SidebarAssignee from './sidebar_assignee.vue'; + +const DATA_REFETCH_DELAY = 250; + +export default { + i18n: { + 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.', + ), + UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR: s__( + 'AlertManagement|This assignee cannot be assigned to this alert.', + ), + ASSIGNEES_BLOCK: s__('AlertManagement|Alert assignee(s): %{assignees}'), + }, + components: { + GlIcon, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlDropdownSectionHeader, + GlSearchBoxByType, + GlLoadingIcon, + GlTooltip, + GlButton, + GlSprintf, + SidebarAssignee, + }, + props: { + projectId: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + alert: { + type: Object, + required: true, + }, + isEditable: { + type: Boolean, + required: false, + default: true, + }, + sidebarCollapsed: { + type: Boolean, + required: false, + }, + }, + data() { + return { + isDropdownShowing: false, + isDropdownSearching: false, + isUpdating: false, + search: '', + users: [], + }; + }, + computed: { + currentUser() { + return gon?.current_username; + }, + userName() { + return this.alert?.assignees?.nodes[0]?.username; + }, + userFullName() { + return this.alert?.assignees?.nodes[0]?.name; + }, + userImg() { + return this.alert?.assignees?.nodes[0]?.avatarUrl; + }, + sortedUsers() { + return this.users + .map((user) => ({ ...user, active: this.isActive(user.username) })) + .sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); // eslint-disable-line no-nested-ternary + }, + dropdownClass() { + return this.isDropdownShowing ? 'dropdown-menu-selectable show' : 'gl-display-none'; + }, + dropDownTitle() { + return this.userName ?? __('Select assignee'); + }, + 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: this.projectId, + }, + }) + .then(({ data }) => { + this.users = data; + }) + .catch(() => { + this.$emit('alert-error', this.$options.i18n.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(({ data: { alertSetAssignees: { errors } = [] } = {} } = {}) => { + this.hideDropdown(); + + if (errors[0]) { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR} ${errors[0]}.`, + ); + } + }) + .catch(() => { + this.$emit('alert-error', this.$options.i18n.UPDATE_ALERT_ASSIGNEES_ERROR); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + }, +}; +</script> + +<template> + <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.assignees" boundary="viewport" placement="left"> + <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK"> + <template #assignees> + {{ userName }} + </template> + </gl-sprintf> + </gl-tooltip> + + <div class="hide-collapsed"> + <p class="title gl-display-flex gl-justify-content-space-between"> + {{ __('Assignee') }} + <a + v-if="isEditable" + ref="editButton" + class="btn-link" + href="#" + @click="toggleFormDropdown" + @keydown.esc="hideDropdown" + > + {{ __('Edit') }} + </a> + </p> + + <gl-dropdown + ref="dropdown" + :text="dropDownTitle" + class="gl-w-full" + :class="dropdownClass" + toggle-class="dropdown-menu-toggle" + @keydown.esc.native="hideDropdown" + @hide="hideDropdown" + > + <p class="gl-new-dropdown-header-top"> + {{ __('Assign To') }} + </p> + <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" /> + <div class="dropdown-content dropdown-body"> + <template v-if="userListValid"> + <gl-dropdown-item + :active="!userName" + active-class="is-active" + @click="updateAlertAssignees('')" + > + {{ __('Unassigned') }} + </gl-dropdown-item> + <gl-dropdown-divider /> + + <gl-dropdown-section-header> + {{ __('Assignee') }} + </gl-dropdown-section-header> + <sidebar-assignee + v-for="user in sortedUsers" + :key="user.username" + :user="user" + :active="user.active" + @update-alert-assignees="updateAlertAssignees" + /> + </template> + <p v-else-if="userListEmpty" class="gl-mx-5 gl-my-4"> + {{ __('No Matching Results') }} + </p> + <gl-loading-icon v-else /> + </div> + </gl-dropdown> + </div> + + <gl-loading-icon v-if="isUpdating" :inline="true" /> + <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 gl-mr-4"> + <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-ml-2" + href="#" + variant="link" + data-testid="unassigned-users" + @click="updateAlertAssignees(currentUser)" + > + {{ __('assign yourself') }} + </gl-button> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue new file mode 100644 index 00000000000..fd40b5d9f65 --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue @@ -0,0 +1,41 @@ +<script> +import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; +import SidebarTodo from './sidebar_todo.vue'; + +export default { + components: { + ToggleSidebar, + SidebarTodo, + }, + props: { + alert: { + type: Object, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + sidebarCollapsed: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <div class="block gl-display-flex gl-justify-content-space-between"> + <span class="issuable-header-text hide-collapsed"> + {{ __('To Do') }} + </span> + <sidebar-todo + v-if="!sidebarCollapsed" + :project-path="projectPath" + :alert="alert" + :sidebar-collapsed="sidebarCollapsed" + @alert-error="$emit('alert-error', $event)" + /> + <toggle-sidebar :collapsed="sidebarCollapsed" @toggle="$emit('toggle-sidebar')" /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue new file mode 100644 index 00000000000..0a2bad5510b --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue @@ -0,0 +1,120 @@ +<script> +import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import AlertStatus from '../alert_status.vue'; + +export default { + statuses: { + TRIGGERED: s__('AlertManagement|Triggered'), + ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), + RESOLVED: s__('AlertManagement|Resolved'), + }, + components: { + GlIcon, + GlLoadingIcon, + GlTooltip, + GlSprintf, + AlertStatus, + }, + props: { + projectPath: { + type: String, + required: true, + }, + alert: { + type: Object, + required: true, + }, + isEditable: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + isDropdownShowing: false, + isUpdating: false, + }; + }, + computed: { + dropdownClass() { + return this.isDropdownShowing ? 'show' : 'gl-display-none'; + }, + }, + methods: { + hideDropdown() { + this.isDropdownShowing = false; + }, + toggleFormDropdown() { + this.isDropdownShowing = !this.isDropdownShowing; + const { dropdown } = this.$children[2].$refs.dropdown.$refs; + if (dropdown && this.isDropdownShowing) { + dropdown.show(); + } + }, + handleUpdating(updating) { + this.isUpdating = updating; + }, + }, +}; +</script> + +<template> + <div class="block alert-status"> + <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')"> + <gl-icon name="status" :size="14" /> + <gl-loading-icon v-if="isUpdating" /> + </div> + <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left"> + <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')"> + <template #status> + {{ alert.status.toLowerCase() }} + </template> + </gl-sprintf> + </gl-tooltip> + + <div class="hide-collapsed"> + <p class="title gl-display-flex justify-content-between"> + {{ s__('AlertManagement|Status') }} + <a + v-if="isEditable" + ref="editButton" + class="btn-link" + href="#" + @click="toggleFormDropdown" + @keydown.esc="hideDropdown" + > + {{ s__('AlertManagement|Edit') }} + </a> + </p> + + <alert-status + :alert="alert" + :project-path="projectPath" + :is-dropdown-showing="isDropdownShowing" + :is-sidebar="true" + @alert-error="$emit('alert-error', $event)" + @hide-dropdown="hideDropdown" + @handle-updating="handleUpdating" + /> + + <gl-loading-icon v-if="isUpdating" :inline="true" /> + <p + v-else-if="!isDropdownShowing" + class="value gl-m-0" + :class="{ 'no-value': !$options.statuses[alert.status] }" + > + <span + v-if="$options.statuses[alert.status]" + class="gl-text-gray-500" + data-testid="status" + >{{ $options.statuses[alert.status] }}</span + > + <span v-else> + {{ s__('AlertManagement|None') }} + </span> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue new file mode 100644 index 00000000000..39ac6c7feca --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue @@ -0,0 +1,149 @@ +<script> +import produce from 'immer'; +import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; +import { s__ } from '~/locale'; +import Todo from '~/sidebar/components/todo_toggle/todo.vue'; +import createAlertTodoMutation from '../../graphql/mutations/alert_todo_create.mutation.graphql'; +import alertQuery from '../../graphql/queries/alert_details.query.graphql'; + +export default { + i18n: { + UPDATE_ALERT_TODO_ERROR: s__( + 'AlertManagement|There was an error while updating the to-do item of the alert.', + ), + }, + components: { + Todo, + }, + props: { + alert: { + type: Object, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + sidebarCollapsed: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isUpdating: false, + }; + }, + computed: { + alertID() { + return parseInt(this.alert.iid, 10); + }, + firstToDoId() { + return this.alert?.todos?.nodes[0]?.id; + }, + hasPendingTodos() { + return this.alert?.todos?.nodes.length > 0; + }, + getAlertQueryVariables() { + return { + fullPath: this.projectPath, + alertId: this.alert.iid, + }; + }, + }, + methods: { + updateToDoCount(add) { + const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10); + const count = add ? oldCount + 1 : oldCount - 1; + const headerTodoEvent = new CustomEvent('todo:toggle', { + detail: { + count, + }, + }); + + return document.dispatchEvent(headerTodoEvent); + }, + addToDo() { + this.isUpdating = true; + return this.$apollo + .mutate({ + mutation: createAlertTodoMutation, + variables: { + iid: this.alert.iid, + projectPath: this.projectPath, + }, + }) + .then(({ data: { errors = [] } }) => { + if (errors[0]) { + return this.throwError(errors[0]); + } + return this.updateToDoCount(true); + }) + .catch(() => { + this.throwError(); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + markAsDone() { + this.isUpdating = true; + return this.$apollo + .mutate({ + mutation: todoMarkDoneMutation, + variables: { + id: this.firstToDoId, + }, + update: this.updateCache, + }) + .then(({ data: { errors = [] } }) => { + if (errors[0]) { + return this.throwError(errors[0]); + } + return this.updateToDoCount(false); + }) + .catch(() => { + this.throwError(); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + updateCache(store) { + const sourceData = store.readQuery({ + query: alertQuery, + variables: this.getAlertQueryVariables, + }); + + const data = produce(sourceData, (draftData) => { + // eslint-disable-next-line no-param-reassign + draftData.project.alertManagementAlerts.nodes[0].todos.nodes = []; + }); + + store.writeQuery({ + query: alertQuery, + variables: this.getAlertQueryVariables, + data, + }); + }, + throwError(err = '') { + const error = err || s__('AlertManagement|Please try again.'); + this.$emit('alert-error', `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${error}`); + }, + }, +}; +</script> + +<template> + <div :class="{ 'block todo': sidebarCollapsed, 'gl-ml-auto': !sidebarCollapsed }"> + <todo + data-testid="alert-todo-button" + :collapsed="sidebarCollapsed" + :issuable-id="alertID" + :is-todo="hasPendingTodos" + :is-action-active="isUpdating" + issuable-type="alert" + @toggleTodo="hasPendingTodos ? markAsDone() : addToDo()" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue new file mode 100644 index 00000000000..3705e36a579 --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue @@ -0,0 +1,48 @@ +<script> +/* eslint-disable vue/no-v-html */ +import { GlIcon } from '@gitlab/ui'; +import NoteHeader from '~/notes/components/note_header.vue'; + +export default { + components: { + NoteHeader, + GlIcon, + }, + props: { + note: { + type: Object, + required: true, + }, + }, + computed: { + noteAnchorId() { + return `note_${this.note?.id?.split('/').pop()}`; + }, + noteAuthor() { + const { + author, + author: { id }, + } = this.note; + return { ...author, id: id?.split('/').pop() }; + }, + }, +}; +</script> + +<template> + <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!"> + <div class="gl-display-inline-flex gl-align-items-center"> + <div + class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6" + > + <gl-icon :name="note.systemNoteIconName" /> + </div> + + <div class="note-header"> + <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id"> + <span v-html="note.bodyHtml"></span> + </note-header> + </div> + </div> + </li> +</template> |