diff options
Diffstat (limited to 'app/assets/javascripts/incidents')
5 files changed, 549 insertions, 0 deletions
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue new file mode 100644 index 00000000000..46852e4ddd9 --- /dev/null +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -0,0 +1,407 @@ +<script> +import { + GlLoadingIcon, + GlTable, + GlAlert, + GlAvatarsInline, + GlAvatarLink, + GlAvatar, + GlTooltipDirective, + GlButton, + GlSearchBoxByType, + GlIcon, + GlPagination, + GlTabs, + GlTab, + GlBadge, + GlEmptyState, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import { s__ } from '~/locale'; +import { mergeUrlParams, joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import getIncidents from '../graphql/queries/get_incidents.query.graphql'; +import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; +import { I18N, DEFAULT_PAGE_SIZE, INCIDENT_SEARCH_DELAY, INCIDENT_STATUS_TABS } from '../constants'; + +const TH_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; +const tdClass = + 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; +const thClass = 'gl-hover-bg-blue-50'; +const bodyTrClass = + 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200'; + +const initialPaginationState = { + currentPage: 1, + prevPageCursor: '', + nextPageCursor: '', + firstPageSize: DEFAULT_PAGE_SIZE, + lastPageSize: null, +}; + +export default { + i18n: I18N, + statusTabs: INCIDENT_STATUS_TABS, + fields: [ + { + key: 'title', + label: s__('IncidentManagement|Incident'), + thClass: `gl-pointer-events-none gl-w-half`, + tdClass, + }, + { + key: 'createdAt', + label: s__('IncidentManagement|Date created'), + thClass, + tdClass: `${tdClass} sortable-cell`, + sortable: true, + thAttr: TH_TEST_ID, + }, + { + key: 'assignees', + label: s__('IncidentManagement|Assignees'), + thClass: 'gl-pointer-events-none', + tdClass, + }, + ], + components: { + GlLoadingIcon, + GlTable, + GlAlert, + GlAvatarsInline, + GlAvatarLink, + GlAvatar, + GlButton, + TimeAgoTooltip, + GlSearchBoxByType, + GlIcon, + GlPagination, + GlTabs, + GlTab, + PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'), + GlBadge, + GlEmptyState, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: [ + 'projectPath', + 'newIssuePath', + 'incidentTemplateName', + 'incidentType', + 'issuePath', + 'publishedAvailable', + 'emptyListSvgPath', + ], + apollo: { + incidents: { + query: getIncidents, + variables() { + return { + searchTerm: this.searchTerm, + status: this.statusFilter, + projectPath: this.projectPath, + issueTypes: ['INCIDENT'], + sort: this.sort, + firstPageSize: this.pagination.firstPageSize, + lastPageSize: this.pagination.lastPageSize, + prevPageCursor: this.pagination.prevPageCursor, + nextPageCursor: this.pagination.nextPageCursor, + }; + }, + update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) { + return { + list: nodes, + pageInfo, + }; + }, + error() { + this.errored = true; + }, + }, + incidentsCount: { + query: getIncidentsCountByStatus, + variables() { + return { + searchTerm: this.searchTerm, + projectPath: this.projectPath, + issueTypes: ['INCIDENT'], + }; + }, + update(data) { + return data.project?.issueStatusCounts; + }, + }, + }, + data() { + return { + errored: false, + isErrorAlertDismissed: false, + redirecting: false, + searchTerm: '', + pagination: initialPaginationState, + incidents: {}, + sort: 'created_desc', + sortBy: 'createdAt', + sortDesc: true, + statusFilter: '', + filteredByStatus: '', + }; + }, + computed: { + showErrorMsg() { + return this.errored && !this.isErrorAlertDismissed; + }, + loading() { + return this.$apollo.queries.incidents.loading; + }, + hasIncidents() { + return this.incidents?.list?.length; + }, + incidentsForCurrentTab() { + return this.incidentsCount?.[this.filteredByStatus.toLowerCase()] ?? 0; + }, + showPaginationControls() { + return Boolean( + this.incidents?.pageInfo?.hasNextPage || this.incidents?.pageInfo?.hasPreviousPage, + ); + }, + prevPage() { + return Math.max(this.pagination.currentPage - 1, 0); + }, + nextPage() { + const nextPage = this.pagination.currentPage + 1; + return nextPage > Math.ceil(this.incidentsForCurrentTab / DEFAULT_PAGE_SIZE) + ? null + : nextPage; + }, + tbodyTrClass() { + return { + [bodyTrClass]: !this.loading && this.hasIncidents, + }; + }, + newIncidentPath() { + return mergeUrlParams( + { + issuable_template: this.incidentTemplateName, + 'issue[issue_type]': this.incidentType, + }, + this.newIssuePath, + ); + }, + availableFields() { + return this.publishedAvailable + ? [ + ...this.$options.fields, + ...[ + { + key: 'published', + label: s__('IncidentManagement|Published'), + thClass: 'gl-pointer-events-none', + }, + ], + ] + : this.$options.fields; + }, + isEmpty() { + return !this.incidents.list?.length; + }, + }, + methods: { + onInputChange: debounce(function debounceSearch(input) { + const trimmedInput = input.trim(); + if (trimmedInput !== this.searchTerm) { + this.searchTerm = trimmedInput; + } + }, INCIDENT_SEARCH_DELAY), + filterIncidentsByStatus(tabIndex) { + const { filters, status } = this.$options.statusTabs[tabIndex]; + this.statusFilter = filters; + this.filteredByStatus = status; + }, + hasAssignees(assignees) { + return Boolean(assignees.nodes?.length); + }, + navigateToIncidentDetails({ iid }) { + return visitUrl(joinPaths(this.issuePath, iid)); + }, + handlePageChange(page) { + const { startCursor, endCursor } = this.incidents.pageInfo; + + if (page > this.pagination.currentPage) { + this.pagination = { + ...initialPaginationState, + nextPageCursor: endCursor, + currentPage: page, + }; + } else { + this.pagination = { + lastPageSize: DEFAULT_PAGE_SIZE, + firstPageSize: null, + prevPageCursor: startCursor, + nextPageCursor: '', + currentPage: page, + }; + } + }, + resetPagination() { + this.pagination = initialPaginationState; + }, + fetchSortedData({ sortBy, sortDesc }) { + const sortingDirection = sortDesc ? 'desc' : 'asc'; + const sortingColumn = convertToSnakeCase(sortBy).replace(/_.*/, ''); + + this.sort = `${sortingColumn}_${sortingDirection}`; + }, + }, +}; +</script> +<template> + <div class="incident-management-list"> + <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true"> + {{ $options.i18n.errorMsg }} + </gl-alert> + + <div + class="incident-management-list-header gl-display-flex gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-gray-100" + > + <gl-tabs content-class="gl-p-0" @input="filterIncidentsByStatus"> + <gl-tab v-for="tab in $options.statusTabs" :key="tab.status" :data-testid="tab.status"> + <template #title> + <span>{{ tab.title }}</span> + <gl-badge v-if="incidentsCount" pill size="sm" class="gl-tab-counter-badge"> + {{ incidentsCount[tab.status.toLowerCase()] }} + </gl-badge> + </template> + </gl-tab> + </gl-tabs> + + <gl-button + v-if="!isEmpty" + class="gl-my-3 gl-mr-5 create-incident-button" + data-testid="createIncidentBtn" + data-qa-selector="create_incident_button" + :loading="redirecting" + :disabled="redirecting" + category="primary" + variant="success" + :href="newIncidentPath" + @click="redirecting = true" + > + {{ $options.i18n.createIncidentBtnLabel }} + </gl-button> + </div> + + <div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100"> + <gl-search-box-by-type + :value="searchTerm" + class="gl-bg-white" + :placeholder="$options.i18n.searchPlaceholder" + @input="onInputChange" + /> + </div> + + <h4 class="gl-display-block d-md-none my-3"> + {{ s__('IncidentManagement|Incidents') }} + </h4> + <gl-table + :items="incidents.list || []" + :fields="availableFields" + :show-empty="true" + :busy="loading" + stacked="md" + :tbody-tr-class="tbodyTrClass" + :no-local-sorting="true" + :sort-direction="'desc'" + :sort-desc.sync="sortDesc" + :sort-by.sync="sortBy" + sort-icon-left + fixed + @row-clicked="navigateToIncidentDetails" + @sort-changed="fetchSortedData" + > + <template #cell(title)="{ item }"> + <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }"> + <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div> + <gl-icon + v-if="item.state === 'closed'" + name="issue-close" + class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0" + :size="16" + data-testid="incident-closed" + /> + </div> + </template> + + <template #cell(createdAt)="{ item }"> + <time-ago-tooltip :time="item.createdAt" /> + </template> + + <template #cell(assignees)="{ item }"> + <div data-testid="incident-assignees"> + <template v-if="hasAssignees(item.assignees)"> + <gl-avatars-inline + :avatars="item.assignees.nodes" + :collapsed="true" + :max-visible="4" + :avatar-size="24" + badge-tooltip-prop="name" + :badge-tooltip-max-chars="100" + > + <template #avatar="{ avatar }"> + <gl-avatar-link + :key="avatar.username" + v-gl-tooltip + target="_blank" + :href="avatar.webUrl" + :title="avatar.name" + > + <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" /> + </gl-avatar-link> + </template> + </gl-avatars-inline> + </template> + <template v-else> + {{ $options.i18n.unassigned }} + </template> + </div> + </template> + + <template v-if="publishedAvailable" #cell(published)="{ item }"> + <published-cell + :status-page-published-incident="item.statusPagePublishedIncident" + :un-published="$options.i18n.unPublished" + /> + </template> + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + + <template #empty> + <gl-empty-state + v-if="!errored" + :title="$options.i18n.emptyState.title" + :svg-path="emptyListSvgPath" + :description="$options.i18n.emptyState.description" + :primary-button-link="newIncidentPath" + :primary-button-text="$options.i18n.createIncidentBtnLabel" + /> + <span v-else> + {{ $options.i18n.noIncidents }} + </span> + </template> + </gl-table> + + <gl-pagination + v-if="showPaginationControls" + :value="pagination.currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="handlePageChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js new file mode 100644 index 00000000000..02d8172533d --- /dev/null +++ b/app/assets/javascripts/incidents/constants.js @@ -0,0 +1,37 @@ +import { s__, __ } from '~/locale'; + +export const I18N = { + errorMsg: s__('IncidentManagement|There was an error displaying the incidents.'), + noIncidents: s__('IncidentManagement|No incidents to display.'), + unassigned: s__('IncidentManagement|Unassigned'), + createIncidentBtnLabel: s__('IncidentManagement|Create incident'), + unPublished: s__('IncidentManagement|Unpublished'), + searchPlaceholder: __('Search results…'), + emptyState: { + title: s__('IncidentManagement|Display your incidents in a dedicated view'), + description: s__( + 'IncidentManagement|All alerts promoted to incidents will automatically be displayed within the list. You can also create a new incident using the button below.', + ), + }, +}; + +export const INCIDENT_STATUS_TABS = [ + { + title: s__('IncidentManagement|Open'), + status: 'OPENED', + filters: 'opened', + }, + { + title: s__('IncidentManagement|Closed'), + status: 'CLOSED', + filters: 'closed', + }, + { + title: s__('IncidentManagement|All'), + status: 'ALL', + filters: 'all', + }, +]; + +export const INCIDENT_SEARCH_DELAY = 300; +export const DEFAULT_PAGE_SIZE = 10; diff --git a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql new file mode 100644 index 00000000000..0b784b104a8 --- /dev/null +++ b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql @@ -0,0 +1,9 @@ +query getIncidentsCountByStatus($searchTerm: String, $projectPath: ID!, $issueTypes: [IssueType!]) { + project(fullPath: $projectPath) { + issueStatusCounts(search: $searchTerm, types: $issueTypes) { + all + opened + closed + } + } +} diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql new file mode 100644 index 00000000000..0f56e8640bd --- /dev/null +++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql @@ -0,0 +1,52 @@ +query getIncidents( + $projectPath: ID! + $issueTypes: [IssueType!] + $sort: IssueSort + $status: IssuableState + $firstPageSize: Int + $lastPageSize: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" + $searchTerm: String +) { + project(fullPath: $projectPath) { + issues( + search: $searchTerm + types: $issueTypes + sort: $sort + state: $status + first: $firstPageSize + last: $lastPageSize + after: $nextPageCursor + before: $prevPageCursor + ) { + nodes { + iid + title + createdAt + state + labels { + nodes { + title + color + } + } + assignees { + nodes { + name + username + avatarUrl + webUrl + } + } + statusPagePublishedIncident + } + pageInfo { + hasNextPage + endCursor + hasPreviousPage + startCursor + } + } + } +} diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js new file mode 100644 index 00000000000..7505d07449c --- /dev/null +++ b/app/assets/javascripts/incidents/list.js @@ -0,0 +1,44 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import IncidentsList from './components/incidents_list.vue'; + +Vue.use(VueApollo); +export default () => { + const selector = '#js-incidents'; + + const domEl = document.querySelector(selector); + const { + projectPath, + newIssuePath, + incidentTemplateName, + incidentType, + issuePath, + publishedAvailable, + emptyListSvgPath, + } = domEl.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el: selector, + provide: { + projectPath, + incidentTemplateName, + incidentType, + newIssuePath, + issuePath, + publishedAvailable, + emptyListSvgPath, + }, + apolloProvider, + components: { + IncidentsList, + }, + render(createElement) { + return createElement('incidents-list'); + }, + }); +}; |