diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/alert_details/components/sidebar')
5 files changed, 647 insertions, 0 deletions
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> |