diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-15 18:08:43 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-15 18:08:43 +0000 |
commit | 316fbf9f95dcdd16775f0339415572c3195eea92 (patch) | |
tree | 40d86a896fc0ff8ce22fbed7e5e3dffc2adceebf /app/assets/javascripts | |
parent | d9e71b0d412fb9d2d7fc8b00dddac21617eaaf19 (diff) | |
download | gitlab-ce-316fbf9f95dcdd16775f0339415572c3195eea92.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
40 files changed, 965 insertions, 785 deletions
diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue index 68443166f40..c5ff2dc0d11 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue @@ -33,30 +33,13 @@ export default { query: alertsHelpUrlQuery, }, }, - props: { - enableAlertManagementPath: { - type: String, - required: true, - }, - userCanEnableAlertManagement: { - type: Boolean, - required: true, - }, - emptyAlertSvgPath: { - type: String, - required: true, - }, - opsgenieMvcEnabled: { - type: Boolean, - required: false, - default: false, - }, - opsgenieMvcTargetUrl: { - type: String, - required: false, - default: '', - }, - }, + inject: [ + 'enableAlertManagementPath', + 'userCanEnableAlertManagement', + 'emptyAlertSvgPath', + 'opsgenieMvcEnabled', + 'opsgenieMvcTargetUrl', + ], data() { return { alertsHelpUrl: '', diff --git a/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue index 094f33fed3b..5e9cdfb3fed 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue @@ -1,6 +1,4 @@ <script> -import Tracking from '~/tracking'; -import { trackAlertListViewsOptions } from '../constants'; import AlertManagementEmptyState from './alert_management_empty_state.vue'; import AlertManagementTable from './alert_management_table.vue'; @@ -9,67 +7,12 @@ export default { AlertManagementEmptyState, AlertManagementTable, }, - props: { - projectPath: { - type: String, - required: true, - }, - alertManagementEnabled: { - type: Boolean, - required: true, - }, - enableAlertManagementPath: { - type: String, - required: true, - }, - populatingAlertsHelpUrl: { - type: String, - required: true, - }, - userCanEnableAlertManagement: { - type: Boolean, - required: true, - }, - emptyAlertSvgPath: { - type: String, - required: true, - }, - opsgenieMvcEnabled: { - type: Boolean, - required: false, - default: false, - }, - opsgenieMvcTargetUrl: { - type: String, - required: false, - default: '', - }, - }, - mounted() { - this.trackPageViews(); - }, - methods: { - trackPageViews() { - const { category, action } = trackAlertListViewsOptions; - Tracking.event(category, action); - }, - }, + inject: ['alertManagementEnabled'], }; </script> <template> <div> - <alert-management-table - v-if="alertManagementEnabled" - :populating-alerts-help-url="populatingAlertsHelpUrl" - :project-path="projectPath" - /> - <alert-management-empty-state - v-else - :empty-alert-svg-path="emptyAlertSvgPath" - :enable-alert-management-path="enableAlertManagementPath" - :user-can-enable-alert-management="userCanEnableAlertManagement" - :opsgenie-mvc-enabled="opsgenieMvcEnabled" - :opsgenie-mvc-target-url="opsgenieMvcTargetUrl" - /> + <alert-management-table v-if="alertManagementEnabled" /> + <alert-management-empty-state v-else /> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 6000acb6aa3..f287b425826 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -1,58 +1,43 @@ <script> -/* eslint-disable vue/no-v-html */ import { + GlAlert, GlLoadingIcon, GlTable, - GlAlert, GlAvatarsInline, GlAvatarLink, GlAvatar, GlIcon, GlLink, - GlTabs, - GlTab, - GlBadge, - GlPagination, - GlSearchBoxByType, GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; -import { debounce, trim } from 'lodash'; -import { __, s__ } from '~/locale'; -import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import { s__, __ } from '~/locale'; import { fetchPolicies } from '~/lib/graphql'; +import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; +import { + tdClass, + thClass, + bodyTrClass, + initialPaginationState, +} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { convertToSnakeCase } from '~/lib/utils/text_utility'; -import Tracking from '~/tracking'; import getAlerts from '../graphql/queries/get_alerts.query.graphql'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import { ALERTS_STATUS_TABS, ALERTS_SEVERITY_LABELS, - DEFAULT_PAGE_SIZE, trackAlertListViewsOptions, - trackAlertStatusUpdateOptions, } from '../constants'; import AlertStatus from './alert_status.vue'; -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-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200'; const TH_TEST_ID = { 'data-testid': 'alert-management-severity-sort' }; -const initialPaginationState = { - currentPage: 1, - prevPageCursor: '', - nextPageCursor: '', - firstPageSize: DEFAULT_PAGE_SIZE, - lastPageSize: null, -}; - const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000; export default { + trackAlertListViewsOptions, i18n: { noAlertsMsg: s__( 'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.', @@ -60,7 +45,6 @@ export default { errorMsg: s__( "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.", ), - searchPlaceholder: __('Search or filter results...'), unassigned: __('Unassigned'), }, fields: [ @@ -115,36 +99,23 @@ export default { severityLabels: ALERTS_SEVERITY_LABELS, statusTabs: ALERTS_STATUS_TABS, components: { + GlAlert, GlLoadingIcon, GlTable, - GlAlert, GlAvatarsInline, GlAvatarLink, GlAvatar, TimeAgo, GlIcon, GlLink, - GlTabs, - GlTab, - GlBadge, - GlPagination, - GlSearchBoxByType, GlSprintf, AlertStatus, + PaginatedTableWithSearchAndTabs, }, directives: { GlTooltip: GlTooltipDirective, }, - props: { - projectPath: { - type: String, - required: true, - }, - populatingAlertsHelpUrl: { - type: String, - required: true, - }, - }, + inject: ['projectPath', 'textQuery', 'assigneeUsernameQuery', 'populatingAlertsHelpUrl'], apollo: { alerts: { fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, @@ -152,6 +123,7 @@ export default { variables() { return { searchTerm: this.searchTerm, + assigneeUsername: this.assigneeUsername, projectPath: this.projectPath, statuses: this.statusFilter, sort: this.sort, @@ -182,14 +154,16 @@ export default { }; }, error() { - this.hasError = true; + this.errored = true; }, }, alertsCount: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, query: getAlertsCountByStatus, variables() { return { searchTerm: this.searchTerm, + assigneeUsername: this.assigneeUsername, projectPath: this.projectPath, }; }, @@ -200,288 +174,234 @@ export default { }, data() { return { - searchTerm: '', - hasError: false, - errorMessage: '', - isAlertDismissed: false, + errored: false, + serverErrorMessage: '', + isErrorAlertDismissed: false, sort: 'STARTED_AT_DESC', statusFilter: [], filteredByStatus: '', - pagination: initialPaginationState, + alerts: {}, + alertsCount: {}, sortBy: 'startedAt', sortDesc: true, sortDirection: 'desc', + searchTerm: this.textQuery, + assigneeUsername: this.assigneeUsernameQuery, + pagination: initialPaginationState, }; }, computed: { + showErrorMsg() { + return this.errored && !this.isErrorAlertDismissed; + }, showNoAlertsMsg() { return ( - !this.hasError && + !this.errored && !this.loading && this.alertsCount?.all === 0 && !this.searchTerm && - !this.isAlertDismissed + !this.assigneeUsername && + !this.isErrorAlertDismissed ); }, loading() { return this.$apollo.queries.alerts.loading; }, - hasAlerts() { - return this.alerts?.list?.length; - }, - showPaginationControls() { - return Boolean(this.prevPage || this.nextPage); - }, - alertsForCurrentTab() { - return this.alertsCount ? this.alertsCount[this.filteredByStatus.toLowerCase()] : 0; - }, - prevPage() { - return Math.max(this.pagination.currentPage - 1, 0); - }, - nextPage() { - const nextPage = this.pagination.currentPage + 1; - return nextPage > Math.ceil(this.alertsForCurrentTab / DEFAULT_PAGE_SIZE) ? null : nextPage; + isEmpty() { + return !this.alerts?.list?.length; }, }, - mounted() { - this.trackPageViews(); - }, methods: { - filterAlertsByStatus(tabIndex) { - this.resetPagination(); - const { filters, status } = this.$options.statusTabs[tabIndex]; - this.statusFilter = filters; - this.filteredByStatus = status; - }, fetchSortedData({ sortBy, sortDesc }) { const sortingDirection = sortDesc ? 'DESC' : 'ASC'; const sortingColumn = convertToSnakeCase(sortBy).toUpperCase(); - this.resetPagination(); + this.pagination = initialPaginationState; this.sort = `${sortingColumn}_${sortingDirection}`; }, - onInputChange: debounce(function debounceSearch(input) { - const trimmedInput = trim(input); - if (trimmedInput !== this.searchTerm) { - this.resetPagination(); - this.searchTerm = trimmedInput; - } - }, 500), navigateToAlertDetails({ iid }, index, { metaKey }) { return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey); }, - trackPageViews() { - const { category, action } = trackAlertListViewsOptions; - Tracking.event(category, action); - }, - trackStatusUpdate(status) { - const { category, action, label } = trackAlertStatusUpdateOptions; - Tracking.event(category, action, { label, property: status }); - }, hasAssignees(assignees) { return Boolean(assignees.nodes?.length); }, getIssueLink(item) { return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid); }, - handlePageChange(page) { - const { startCursor, endCursor } = this.alerts.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; - }, tbodyTrClass(item) { return { - [bodyTrClass]: !this.loading && this.hasAlerts, + [bodyTrClass]: !this.loading && !this.isEmpty, 'new-alert': item?.isNew, }; }, handleAlertError(errorMessage) { - this.hasError = true; - this.errorMessage = errorMessage; + this.errored = true; + this.serverErrorMessage = errorMessage; }, - dismissError() { - this.hasError = false; - this.errorMessage = ''; + handleStatusUpdate() { + this.$apollo.queries.alerts.refetch(); + this.$apollo.queries.alertsCount.refetch(); + }, + pageChanged(pagination) { + this.pagination = pagination; + }, + statusChanged({ filters, status }) { + this.statusFilter = filters; + this.filteredByStatus = status; + }, + filtersChanged({ searchTerm, assigneeUsername }) { + this.searchTerm = searchTerm; + this.assigneeUsername = assigneeUsername; + }, + errorAlertDismissed() { + this.errored = false; + this.serverErrorMessage = ''; + this.isErrorAlertDismissed = true; }, }, }; </script> <template> <div> - <div class="incident-management-list"> - <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true"> - <gl-sprintf :message="$options.i18n.noAlertsMsg"> - <template #link="{ content }"> - <gl-link - class="gl-display-inline-block" - :href="populatingAlertsHelpUrl" - target="_blank" - > - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> - <gl-alert v-if="hasError" variant="danger" data-testid="alert-error" @dismiss="dismissError"> - <p v-html="errorMessage || $options.i18n.errorMsg"></p> - </gl-alert> - - <gl-tabs - content-class="gl-p-0 gl-border-b-solid gl-border-b-1 gl-border-gray-100" - @input="filterAlertsByStatus" - > - <gl-tab v-for="tab in $options.statusTabs" :key="tab.status"> - <template slot="title"> - <span>{{ tab.title }}</span> - <gl-badge v-if="alertsCount" pill size="sm" class="gl-tab-counter-badge"> - {{ alertsCount[tab.status.toLowerCase()] }} - </gl-badge> - </template> - </gl-tab> - </gl-tabs> + <gl-alert v-if="showNoAlertsMsg" @dismiss="errorAlertDismissed"> + <gl-sprintf :message="$options.i18n.noAlertsMsg"> + <template #link="{ content }"> + <gl-link class="gl-display-inline-block" :href="populatingAlertsHelpUrl" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> - <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 - class="gl-bg-white" - :placeholder="$options.i18n.searchPlaceholder" - @input="onInputChange" - /> - </div> + <paginated-table-with-search-and-tabs + :show-error-msg="showErrorMsg" + :i18n="$options.i18n" + :items="alerts.list || []" + :page-info="alerts.pageInfo" + :items-count="alertsCount" + :status-tabs="$options.statusTabs" + :track-views-options="$options.trackAlertListViewsOptions" + :server-error-message="serverErrorMessage" + :filter-search-tokens="['assignee_username']" + filter-search-key="alerts" + @page-changed="pageChanged" + @tabs-changed="statusChanged" + @filters-changed="filtersChanged" + @error-alert-dismissed="errorAlertDismissed" + > + <template #header-actions></template> - <h4 class="d-block d-md-none my-3"> + <template #title> {{ s__('AlertManagement|Alerts') }} - </h4> - <gl-table - class="alert-management-table" - :items="alerts ? alerts.list : []" - :fields="$options.fields" - :show-empty="true" - :busy="loading" - stacked="md" - :tbody-tr-class="tbodyTrClass" - :no-local-sorting="true" - :sort-direction="sortDirection" - :sort-desc.sync="sortDesc" - :sort-by.sync="sortBy" - sort-icon-left - fixed - @row-clicked="navigateToAlertDetails" - @sort-changed="fetchSortedData" - > - <template #cell(severity)="{ item }"> - <div - class="d-inline-flex align-items-center justify-content-between" - data-testid="severityField" - > - <gl-icon - class="mr-2" - :size="12" - :name="`severity-${item.severity.toLowerCase()}`" - :class="`icon-${item.severity.toLowerCase()}`" - /> - {{ $options.severityLabels[item.severity] }} - </div> - </template> + </template> - <template #cell(startedAt)="{ item }"> - <time-ago v-if="item.startedAt" :time="item.startedAt" /> - </template> + <template #table> + <gl-table + class="alert-management-table" + :items="alerts ? alerts.list : []" + :fields="$options.fields" + :show-empty="true" + :busy="loading" + stacked="md" + :tbody-tr-class="tbodyTrClass" + :no-local-sorting="true" + :sort-direction="sortDirection" + :sort-desc.sync="sortDesc" + :sort-by.sync="sortBy" + sort-icon-left + fixed + @row-clicked="navigateToAlertDetails" + @sort-changed="fetchSortedData" + > + <template #cell(severity)="{ item }"> + <div + class="d-inline-flex align-items-center justify-content-between" + data-testid="severityField" + > + <gl-icon + class="mr-2" + :size="12" + :name="`severity-${item.severity.toLowerCase()}`" + :class="`icon-${item.severity.toLowerCase()}`" + /> + {{ $options.severityLabels[item.severity] }} + </div> + </template> - <template #cell(eventCount)="{ item }"> - {{ item.eventCount }} - </template> + <template #cell(startedAt)="{ item }"> + <time-ago v-if="item.startedAt" :time="item.startedAt" /> + </template> - <template #cell(alertLabel)="{ item }"> - <div - class="gl-max-w-full text-truncate" - :title="`${item.iid} - ${item.title}`" - data-testid="idField" - > - #{{ item.iid }} {{ item.title }} - </div> - </template> + <template #cell(eventCount)="{ item }"> + {{ item.eventCount }} + </template> - <template #cell(issue)="{ item }"> - <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)"> - #{{ item.issueIid }} - </gl-link> - <div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div> - </template> + <template #cell(alertLabel)="{ item }"> + <div + class="gl-max-w-full text-truncate" + :title="`${item.iid} - ${item.title}`" + data-testid="idField" + > + #{{ item.iid }} {{ item.title }} + </div> + </template> - <template #cell(assignees)="{ item }"> - <div data-testid="assigneesField"> - <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 #cell(issue)="{ item }"> + <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)"> + #{{ item.issueIid }} + </gl-link> + <div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div> + </template> - <template #cell(status)="{ item }"> - <alert-status - :alert="item" - :project-path="projectPath" - :is-sidebar="false" - @alert-error="handleAlertError" - /> - </template> + <template #cell(assignees)="{ item }"> + <div data-testid="assigneesField"> + <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 #empty> - {{ s__('AlertManagement|No alerts to display.') }} - </template> + <template #cell(status)="{ item }"> + <alert-status + :alert="item" + :project-path="projectPath" + :is-sidebar="false" + @alert-error="handleAlertError" + @hide-dropdown="handleStatusUpdate" + /> + </template> - <template #table-busy> - <gl-loading-icon size="lg" color="dark" class="mt-3" /> - </template> - </gl-table> + <template #empty> + {{ s__('AlertManagement|No alerts to display.') }} + </template> - <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 #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + </gl-table> + </template> + </paginated-table-with-search-and-tabs> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue index c505ef6c15b..3083a85cbd9 100644 --- a/app/assets/javascripts/alert_management/components/alert_status.vue +++ b/app/assets/javascripts/alert_management/components/alert_status.vue @@ -3,7 +3,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; import { trackAlertStatusUpdateOptions } from '../constants'; -import updateAlertStatus from '../graphql/mutations/update_alert_status.mutation.graphql'; +import updateAlertStatusMutation from '../graphql/mutations/update_alert_status.mutation.graphql'; export default { i18n: { @@ -50,7 +50,7 @@ export default { this.$emit('handle-updating', true); this.$apollo .mutate({ - mutation: updateAlertStatus, + mutation: updateAlertStatusMutation, variables: { iid: this.alert.iid, status: status.toUpperCase(), @@ -59,8 +59,6 @@ export default { }) .then(resp => { this.trackStatusUpdate(status); - this.$emit('hide-dropdown'); - const errors = resp.data?.updateAlertStatus?.errors || []; if (errors[0]) { @@ -69,6 +67,8 @@ export default { `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`, ); } + + this.$emit('hide-dropdown'); }) .catch(() => { this.$emit( diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue index 2e667bf99a8..5e4fd56738b 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -229,11 +229,7 @@ export default { <p class="gl-new-dropdown-header-top"> {{ __('Assign To') }} </p> - <gl-search-box-by-type - v-model.trim="search" - class="m-2" - :placeholder="__('Search users')" - /> + <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 diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js index 73cb5ecdf98..b79a64646eb 100644 --- a/app/assets/javascripts/alert_management/constants.js +++ b/app/assets/javascripts/alert_management/constants.js @@ -63,5 +63,3 @@ export const trackAlertStatusUpdateOptions = { action: 'update_alert_status', label: 'Status', }; - -export const DEFAULT_PAGE_SIZE = 20; diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql index 8ac00bbc6b5..bc7e51a2e90 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql @@ -1,7 +1,6 @@ #import "../fragments/list_item.fragment.graphql" query getAlerts( - $searchTerm: String $projectPath: ID! $statuses: [AlertManagementStatus!] $sort: AlertManagementAlertSort @@ -9,10 +8,13 @@ query getAlerts( $lastPageSize: Int $prevPageCursor: String = "" $nextPageCursor: String = "" + $searchTerm: String = "" + $assigneeUsername: String = "" ) { project(fullPath: $projectPath) { alertManagementAlerts( search: $searchTerm + assigneeUsername: $assigneeUsername statuses: $statuses sort: $sort first: $firstPageSize diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql index 5a6faea5cd8..40ec4c56171 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql @@ -1,6 +1,6 @@ -query getAlertsCount($searchTerm: String, $projectPath: ID!) { +query getAlertsCount($searchTerm: String, $projectPath: ID!, $assigneeUsername: String = "") { project(fullPath: $projectPath) { - alertManagementAlertStatusCounts(search: $searchTerm) { + alertManagementAlertStatusCounts(search: $searchTerm, assigneeUsername: $assigneeUsername) { all open acknowledged diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index e180ab5f7e3..e34450204fb 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -18,12 +18,12 @@ export default () => { populatingAlertsHelpUrl, alertsHelpUrl, opsgenieMvcTargetUrl, + textQuery, + assigneeUsernameQuery, + alertManagementEnabled, + userCanEnableAlertManagement, + opsgenieMvcEnabled, } = domEl.dataset; - let { alertManagementEnabled, userCanEnableAlertManagement, opsgenieMvcEnabled } = domEl.dataset; - - alertManagementEnabled = parseBoolean(alertManagementEnabled); - userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement); - opsgenieMvcEnabled = parseBoolean(opsgenieMvcEnabled); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( @@ -50,23 +50,24 @@ export default () => { return new Vue({ el: selector, + provide: { + projectPath, + textQuery, + assigneeUsernameQuery, + enableAlertManagementPath, + populatingAlertsHelpUrl, + emptyAlertSvgPath, + opsgenieMvcTargetUrl, + alertManagementEnabled: parseBoolean(alertManagementEnabled), + userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement), + opsgenieMvcEnabled: parseBoolean(opsgenieMvcEnabled), + }, apolloProvider, components: { AlertManagementList, }, render(createElement) { - return createElement('alert-management-list', { - props: { - projectPath, - enableAlertManagementPath, - populatingAlertsHelpUrl, - emptyAlertSvgPath, - alertManagementEnabled, - userCanEnableAlertManagement, - opsgenieMvcTargetUrl, - opsgenieMvcEnabled, - }, - }); + return createElement('alert-management-list'); }, }); }; diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 9a746d2baa7..6a44f87d0e7 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -388,7 +388,7 @@ export default { <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> <h5 class="gl-font-lg">{{ $options.i18n.integrationsLabel }}</h5> - <gl-form-group label-for="integrations" label-class="gl-font-weight-bold"> + <gl-form-group label-for="integrations"> <div data-testid="alert-settings-description" class="gl-mt-5"> <p v-for="section in sections" :key="section.text"> <gl-sprintf :message="section.text"> @@ -417,11 +417,7 @@ export default { </gl-sprintf> </span> </gl-form-group> - <gl-form-group - :label="$options.i18n.activeLabel" - label-for="activated" - label-class="gl-font-weight-bold" - > + <gl-form-group :label="$options.i18n.activeLabel" label-for="activated"> <toggle-button id="activated" :disabled-input="loading" @@ -434,7 +430,6 @@ export default { v-if="isOpsgenie || isPrometheus" :label="$options.i18n.apiBaseUrlLabel" label-for="api-url" - label-class="gl-font-weight-bold" > <gl-form-input id="api-url" @@ -448,11 +443,7 @@ export default { </span> </gl-form-group> <template v-if="!isOpsgenie"> - <gl-form-group - :label="$options.i18n.urlLabel" - label-for="url" - label-class="gl-font-weight-bold" - > + <gl-form-group :label="$options.i18n.urlLabel" label-for="url"> <gl-form-input-group id="url" readonly :value="selectedService.url"> <template #append> <clipboard-button @@ -466,11 +457,7 @@ export default { {{ prometheusInfo }} </span> </gl-form-group> - <gl-form-group - :label="$options.i18n.authKeyLabel" - label-for="authorization-key" - label-class="gl-font-weight-bold" - > + <gl-form-group :label="$options.i18n.authKeyLabel" label-for="authorization-key"> <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="authKey"> <template #append> <clipboard-button @@ -496,7 +483,6 @@ export default { <gl-form-group :label="$options.i18n.alertJson" label-for="alert-json" - label-class="gl-font-weight-bold" :invalid-feedback="testAlert.error" > <gl-form-textarea diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue new file mode 100644 index 00000000000..0f063c7582e --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -0,0 +1,120 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlLabel } from '@gitlab/ui'; +import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +export default { + components: { + BoardEditableItem, + LabelsSelect, + GlLabel, + }, + data() { + return { + loading: false, + }; + }, + inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], + computed: { + ...mapGetters({ issue: 'getActiveIssue' }), + selectedLabels() { + const { labels = [] } = this.issue; + + return labels.map(label => ({ + ...label, + id: getIdFromGraphQLId(label.id), + })); + }, + issueLabels() { + const { labels = [] } = this.issue; + + return labels.map(label => ({ + ...label, + scoped: isScopedLabel(label), + })); + }, + projectPath() { + const { referencePath = '' } = this.issue; + return referencePath.slice(0, referencePath.indexOf('#')); + }, + }, + methods: { + ...mapActions(['setActiveIssueLabels']), + async setLabels(payload) { + this.loading = true; + this.$refs.sidebarItem.collapse(); + + try { + const addLabelIds = payload.filter(label => label.set).map(label => label.id); + const removeLabelIds = this.selectedLabels + .filter(label => !payload.find(selected => selected.id === label.id)) + .map(label => label.id); + + const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath }; + await this.setActiveIssueLabels(input); + } catch (e) { + createFlash({ message: __('An error occurred while updating labels.') }); + } finally { + this.loading = false; + } + }, + async removeLabel(id) { + this.loading = true; + + try { + const removeLabelIds = [getIdFromGraphQLId(id)]; + const input = { removeLabelIds, projectPath: this.projectPath }; + await this.setActiveIssueLabels(input); + } catch (e) { + createFlash({ message: __('An error occurred when removing the label.') }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <board-editable-item ref="sidebarItem" :title="__('Labels')" :loading="loading"> + <template #collapsed> + <gl-label + v-for="label in issueLabels" + :key="label.id" + :background-color="label.color" + :title="label.title" + :description="label.description" + :scoped="label.scoped" + :show-close-button="true" + :disabled="loading" + class="gl-mr-2 gl-mb-2" + @close="removeLabel(label.id)" + /> + </template> + <template> + <labels-select + ref="labelsSelect" + :allow-label-edit="false" + :allow-label-create="false" + :allow-multiselect="true" + :allow-scoped-labels="true" + :selected-labels="selectedLabels" + :labels-fetch-path="labelsFetchPath" + :labels-manage-path="labelsManagePath" + :labels-filter-base-path="labelsFilterBasePath" + :labels-list-title="__('Select label')" + :dropdown-button-text="__('Choose labels')" + variant="embedded" + class="gl-display-block labels gl-w-full" + @updateSelectedLabels="setLabels" + > + {{ __('None') }} + </labels-select> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index dc4ccc93951..9b501a3c6b8 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -87,6 +87,9 @@ export default () => { groupId: Number($boardApp.dataset.groupId), rootPath: $boardApp.dataset.rootPath, canUpdate: $boardApp.dataset.canUpdate, + labelsFetchPath: $boardApp.dataset.labelsFetchPath, + labelsManagePath: $boardApp.dataset.labelsManagePath, + labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath, }, store, apolloProvider, @@ -369,6 +372,10 @@ export default () => { toggleFocusMode(ModalStore, boardsStore); toggleLabels(); - toggleEpicsSwimlanes(); + + if (gon.features?.swimlanes) { + toggleEpicsSwimlanes(); + } + mountMultipleBoardsSwitcher(); }; diff --git a/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql new file mode 100644 index 00000000000..3c5f4b3e3bd --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql @@ -0,0 +1,15 @@ +mutation issueSetLabels($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + labels { + nodes { + id + title + color + description + } + } + } + errors + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index a1fd05f2a3b..bd1bf17b0c7 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -19,6 +19,7 @@ import boardListsQuery from '../queries/board_lists.query.graphql'; import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; import updateBoardListMutation from '../queries/board_list_update.mutation.graphql'; import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; +import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -281,6 +282,31 @@ export default { commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue }); }, + setActiveIssueLabels: async ({ commit, getters }, input) => { + const activeIssue = getters.getActiveIssue; + const { data } = await gqlClient.mutate({ + mutation: issueSetLabels, + variables: { + input: { + iid: String(activeIssue.iid), + addLabelIds: input.addLabelIds ?? [], + removeLabelIds: input.removeLabelIds ?? [], + projectPath: input.projectPath, + }, + }, + }); + + if (data.updateIssue?.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: activeIssue.id, + prop: 'labels', + value: data.updateIssue.issue.labels.nodes, + }); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 9279d18ff1e..89a3b14b262 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -5,7 +5,7 @@ export default { getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), isSidebarOpen: state => state.activeId !== inactiveId, isSwimlanesOn: state => { - if (!gon?.features?.boardsWithSwimlanes) { + if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) { return false; } diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue index 5bae2751a17..ceb94b1f0f8 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue @@ -60,7 +60,7 @@ export default { </script> <template> <gl-dropdown :text="value"> - <gl-search-box-by-type v-model.trim="searchTerm" class="gl-m-3" /> + <gl-search-box-by-type v-model.trim="searchTerm" /> <gl-dropdown-item v-for="environment in filteredResults" :key="environment" diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index 19ce3e36cd7..cb415d902e8 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -130,7 +130,6 @@ export default { <gl-search-box-by-type v-model.trim="searchQuery" :placeholder="s__('ClusterIntegration|Search domains')" - class="gl-m-3" /> <gl-dropdown-item v-for="domain in filteredDomains" diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue index 2888746005e..f1371c0320d 100644 --- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue +++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue @@ -80,7 +80,6 @@ export default { <gl-search-box-by-type ref="searchBox" v-model.trim="environmentSearch" - class="gl-m-3" @focus="fetchEnvironments" @keyup="fetchEnvironments" /> diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 3ecd911e814..245d71ce55f 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -2,41 +2,32 @@ import { GlLoadingIcon, GlTable, - GlAlert, GlAvatarsInline, GlAvatarLink, GlAvatar, GlTooltipDirective, GlButton, GlIcon, - GlPagination, - GlTabs, - GlTab, - GlBadge, GlEmptyState, } from '@gitlab/ui'; -import Api from '~/api'; import Tracking from '~/tracking'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; -import { convertToSnakeCase } from '~/lib/utils/text_utility'; -import { s__, __ } from '~/locale'; -import { urlParamsToObject } from '~/lib/utils/common_utils'; +import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; import { - visitUrl, - mergeUrlParams, - joinPaths, - updateHistory, - setUrlParams, -} from '~/lib/utils/url_utility'; + tdClass, + thClass, + bodyTrClass, + initialPaginationState, +} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; +import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import { s__ } from '~/locale'; +import { visitUrl, mergeUrlParams, joinPaths } 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 SeverityToken from '~/sidebar/components/severity/severity.vue'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; import { I18N, - DEFAULT_PAGE_SIZE, INCIDENT_STATUS_TABS, TH_CREATED_AT_TEST_ID, TH_INCIDENT_SLA_TEST_ID, @@ -44,24 +35,12 @@ import { TH_PUBLISHED_TEST_ID, INCIDENT_DETAILS_PATH, trackIncidentCreateNewOptions, + trackIncidentListViewsOptions, } from '../constants'; -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 { trackIncidentCreateNewOptions, + trackIncidentListViewsOptions, i18n: I18N, statusTabs: INCIDENT_STATUS_TABS, fields: [ @@ -112,23 +91,18 @@ export default { components: { GlLoadingIcon, GlTable, - GlAlert, GlAvatarsInline, GlAvatarLink, GlAvatar, GlButton, TimeAgoTooltip, GlIcon, - GlPagination, - GlTabs, - GlTab, PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'), ServiceLevelAgreementCell: () => import('ee_component/incidents/components/service_level_agreement_cell.vue'), - GlBadge, GlEmptyState, SeverityToken, - FilteredSearchBar, + PaginatedTableWithSearchAndTabs, }, directives: { GlTooltip: GlTooltipDirective, @@ -142,8 +116,8 @@ export default { 'publishedAvailable', 'emptyListSvgPath', 'textQuery', - 'authorUsernamesQuery', - 'assigneeUsernamesQuery', + 'authorUsernameQuery', + 'assigneeUsernameQuery', 'slaFeatureAvailable', ], apollo: { @@ -152,16 +126,16 @@ export default { variables() { return { searchTerm: this.searchTerm, - status: this.statusFilter, + authorUsername: this.authorUsername, + assigneeUsername: this.assigneeUsername, projectPath: this.projectPath, + status: this.statusFilter, issueTypes: ['INCIDENT'], sort: this.sort, firstPageSize: this.pagination.firstPageSize, lastPageSize: this.pagination.lastPageSize, prevPageCursor: this.pagination.prevPageCursor, nextPageCursor: this.pagination.nextPageCursor, - authorUsername: this.authorUsername, - assigneeUsernames: this.assigneeUsernames, }; }, update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) { @@ -180,7 +154,7 @@ export default { return { searchTerm: this.searchTerm, authorUsername: this.authorUsername, - assigneeUsernames: this.assigneeUsernames, + assigneeUsername: this.assigneeUsername, projectPath: this.projectPath, issueTypes: ['INCIDENT'], }; @@ -195,17 +169,17 @@ export default { errored: false, isErrorAlertDismissed: false, redirecting: false, - searchTerm: this.textQuery, - pagination: initialPaginationState, incidents: {}, + incidentsCount: {}, sort: 'created_desc', sortBy: 'createdAt', sortDesc: true, statusFilter: '', filteredByStatus: '', - authorUsername: this.authorUsernamesQuery, - assigneeUsernames: this.assigneeUsernamesQuery, - filterParams: {}, + searchTerm: this.textQuery, + authorUsername: this.authorUsernameQuery, + assigneeUsername: this.assigneeUsernameQuery, + pagination: initialPaginationState, }; }, computed: { @@ -215,29 +189,15 @@ export default { 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); + isEmpty() { + return !this.incidents?.list?.length; }, - nextPage() { - const nextPage = this.pagination.currentPage + 1; - return nextPage > Math.ceil(this.incidentsForCurrentTab / DEFAULT_PAGE_SIZE) - ? null - : nextPage; + showList() { + return !this.isEmpty || this.errored || this.loading; }, tbodyTrClass() { return { - [bodyTrClass]: !this.loading && this.hasIncidents, + [bodyTrClass]: !this.loading && !this.isEmpty, }; }, newIncidentPath() { @@ -257,12 +217,6 @@ export default { return this.$options.fields.filter(({ key }) => !isHidden[key]); }, - isEmpty() { - return !this.incidents.list?.length; - }, - showList() { - return !this.isEmpty || this.errored || this.loading; - }, activeClosedTabHasNoIncidents() { const { all, closed } = this.incidentsCount || {}; const isClosedTabActive = this.statusFilter === this.$options.statusTabs[1].filters; @@ -285,63 +239,8 @@ export default { btnText: createIncidentBtnLabel, }; }, - filteredSearchTokens() { - return [ - { - type: 'author_username', - icon: 'user', - title: __('Author'), - unique: true, - symbol: '@', - token: AuthorToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], - fetchPath: this.projectPath, - fetchAuthors: Api.projectUsers.bind(Api), - }, - { - type: 'assignee_username', - icon: 'user', - title: __('Assignees'), - unique: true, - symbol: '@', - token: AuthorToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], - fetchPath: this.projectPath, - fetchAuthors: Api.projectUsers.bind(Api), - }, - ]; - }, - filteredSearchValue() { - const value = []; - - if (this.authorUsername) { - value.push({ - type: 'author_username', - value: { data: this.authorUsername }, - }); - } - - if (this.assigneeUsernames) { - value.push({ - type: 'assignee_username', - value: { data: this.assigneeUsernames }, - }); - } - - if (this.searchTerm) { - value.push(this.searchTerm); - } - - return value; - }, }, methods: { - filterIncidentsByStatus(tabIndex) { - this.resetPagination(); - const { filters, status } = this.$options.statusTabs[tabIndex]; - this.statusFilter = filters; - this.filteredByStatus = status; - }, hasAssignees(assignees) { return Boolean(assignees.nodes?.length); }, @@ -353,255 +252,170 @@ export default { Tracking.event(category, action); this.redirecting = true; }, - 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(/_.*/, '') .toUpperCase(); - this.resetPagination(); + this.pagination = initialPaginationState; this.sort = `${sortingColumn}_${sortingDirection}`; }, getSeverity(severity) { return INCIDENT_SEVERITY[severity]; }, - handleFilterIncidents(filters) { - this.resetPagination(); - const filterParams = { authorUsername: '', assigneeUsername: '', search: '' }; - - filters.forEach(filter => { - if (typeof filter === 'object') { - switch (filter.type) { - case 'author_username': - filterParams.authorUsername = filter.value.data; - break; - case 'assignee_username': - filterParams.assigneeUsername = filter.value.data; - break; - case 'filtered-search-term': - if (filter.value.data !== '') filterParams.search = filter.value.data; - break; - default: - break; - } - } - }); - - this.filterParams = filterParams; - this.updateUrl(); - this.searchTerm = filterParams?.search; - this.authorUsername = filterParams?.authorUsername; - this.assigneeUsernames = filterParams?.assigneeUsername; + pageChanged(pagination) { + this.pagination = pagination; }, - updateUrl() { - const queryParams = urlParamsToObject(window.location.search); - const { authorUsername, assigneeUsername, search } = this.filterParams || {}; - - if (authorUsername) { - queryParams.author_username = authorUsername; - } else { - delete queryParams.author_username; - } - - if (assigneeUsername) { - queryParams.assignee_username = assigneeUsername; - } else { - delete queryParams.assignee_username; - } - - if (search) { - queryParams.search = search; - } else { - delete queryParams.search; - } - - updateHistory({ - url: setUrlParams(queryParams, window.location.href, true), - title: document.title, - replace: true, - }); + statusChanged({ filters, status }) { + this.statusFilter = filters; + this.filteredByStatus = status; + }, + filtersChanged({ searchTerm, authorUsername, assigneeUsername }) { + this.searchTerm = searchTerm; + this.authorUsername = authorUsername; + this.assigneeUsername = assigneeUsername; + }, + errorAlertDismissed() { + this.isErrorAlertDismissed = true; }, }, }; </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 || activeClosedTabHasNoIncidents" - 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="navigateToCreateNewIncident" - > - {{ $options.i18n.createIncidentBtnLabel }} - </gl-button> - </div> - - <div class="filtered-search-wrapper"> - <filtered-search-bar - :namespace="projectPath" - :search-input-placeholder="$options.i18n.searchPlaceholder" - :tokens="filteredSearchTokens" - :initial-filter-value="filteredSearchValue" - initial-sortby="created_desc" - recent-searches-storage-key="incidents" - class="row-content-block" - @onFilter="handleFilterIncidents" - /> - </div> - - <h4 class="gl-display-block d-md-none my-3"> - {{ s__('IncidentManagement|Incidents') }} - </h4> - <gl-table - v-if="showList" + <div> + <paginated-table-with-search-and-tabs + :show-items="showList" + :show-error-msg="showErrorMsg" + :i18n="$options.i18n" :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" + :page-info="incidents.pageInfo" + :items-count="incidentsCount" + :status-tabs="$options.statusTabs" + :track-views-options="$options.trackIncidentListViewsOptions" + filter-search-key="incidents" + @page-changed="pageChanged" + @tabs-changed="statusChanged" + @filters-changed="filtersChanged" + @error-alert-dismissed="errorAlertDismissed" > - <template #cell(severity)="{ item }"> - <severity-token :severity="getSeverity(item.severity)" /> + <template #header-actions> + <gl-button + v-if="!isEmpty || activeClosedTabHasNoIncidents" + 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> </template> - <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 #title> + {{ s__('IncidentManagement|Incidents') }} </template> - <template #cell(createdAt)="{ item }"> - <time-ago-tooltip :time="item.createdAt" /> - </template> + <template #table> + <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(severity)="{ item }"> + <severity-token :severity="getSeverity(item.severity)" /> + </template> - <template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }"> - <service-level-agreement-cell :sla-due-at="item.slaDueAt" data-testid="incident-sla" /> - </template> + <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(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" + <template #cell(createdAt)="{ item }"> + <time-ago-tooltip :time="item.createdAt" /> + </template> + + <template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }"> + <service-level-agreement-cell :sla-due-at="item.slaDueAt" data-testid="incident-sla" /> + </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" > - <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" /> - </gl-avatar-link> + <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> - </gl-avatars-inline> + </div> </template> - <template v-else> - {{ $options.i18n.unassigned }} + + <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> - </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 v-if="errored" #empty> + {{ $options.i18n.noIncidents }} + </template> + </gl-table> </template> - - <template v-if="errored" #empty> - {{ $options.i18n.noIncidents }} + <template #emtpy-state> + <gl-empty-state + :title="emptyStateData.title" + :svg-path="emptyListSvgPath" + :description="emptyStateData.description" + :primary-button-link="emptyStateData.btnLink" + :primary-button-text="emptyStateData.btnText" + /> </template> - </gl-table> - - <gl-empty-state - v-else - :title="emptyStateData.title" - :svg-path="emptyListSvgPath" - :description="emptyStateData.description" - :primary-button-link="emptyStateData.btnLink" - :primary-button-text="emptyStateData.btnText" - /> - - <gl-pagination - v-if="showPaginationControls" - :value="pagination.currentPage" - :prev-page="prevPage" - :next-page="nextPage" - align="center" - class="gl-pagination gl-mt-3" - @input="handlePageChange" - /> + </paginated-table-with-search-and-tabs> </div> </template> diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js index 4fccefb66c5..9c31a5702a2 100644 --- a/app/assets/javascripts/incidents/constants.js +++ b/app/assets/javascripts/incidents/constants.js @@ -1,5 +1,5 @@ /* eslint-disable @gitlab/require-i18n-strings */ -import { s__, __ } from '~/locale'; +import { s__ } from '~/locale'; export const I18N = { errorMsg: s__('IncidentManagement|There was an error displaying the incidents.'), @@ -7,7 +7,6 @@ export const I18N = { unassigned: s__('IncidentManagement|Unassigned'), createIncidentBtnLabel: s__('IncidentManagement|Create incident'), unPublished: s__('IncidentManagement|Unpublished'), - searchPlaceholder: __('Search or filter results…'), emptyState: { title: s__('IncidentManagement|Display your incidents in a dedicated view'), emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'), @@ -43,6 +42,14 @@ export const trackIncidentCreateNewOptions = { action: 'create_incident_button_clicks', }; +/** + * Tracks snowplow event when user views incident list + */ +export const trackIncidentListViewsOptions = { + category: 'Incident Management', + action: 'view_incidents_list', +}; + export const DEFAULT_PAGE_SIZE = 20; export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; 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 index fd96825c0f7..4e44a506c4f 100644 --- 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 @@ -3,14 +3,14 @@ query getIncidentsCountByStatus( $projectPath: ID! $issueTypes: [IssueType!] $authorUsername: String = "" - $assigneeUsernames: String = "" + $assigneeUsername: String = "" ) { project(fullPath: $projectPath) { issueStatusCounts( search: $searchTerm types: $issueTypes authorUsername: $authorUsername - assigneeUsername: $assigneeUsernames + assigneeUsername: $assigneeUsername ) { all opened diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql index dd2a42ba4e8..f97664a3b77 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql @@ -11,7 +11,7 @@ query getIncidents( $nextPageCursor: String = "" $searchTerm: String = "" $authorUsername: String = "" - $assigneeUsernames: String = "" + $assigneeUsername: String = "" ) { project(fullPath: $projectPath) { issues( @@ -20,7 +20,7 @@ query getIncidents( sort: $sort state: $status authorUsername: $authorUsername - assigneeUsername: $assigneeUsernames + assigneeUsername: $assigneeUsername first: $firstPageSize last: $lastPageSize after: $nextPageCursor diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js index 15af7432436..6f87fbbe775 100644 --- a/app/assets/javascripts/incidents/list.js +++ b/app/assets/javascripts/incidents/list.js @@ -18,8 +18,8 @@ export default () => { publishedAvailable, emptyListSvgPath, textQuery, - authorUsernamesQuery, - assigneeUsernamesQuery, + authorUsernameQuery, + assigneeUsernameQuery, slaFeatureAvailable, } = domEl.dataset; @@ -38,8 +38,8 @@ export default () => { publishedAvailable: parseBoolean(publishedAvailable), emptyListSvgPath, textQuery, - authorUsernamesQuery, - assigneeUsernamesQuery, + authorUsernameQuery, + assigneeUsernameQuery, slaFeatureAvailable: parseBoolean(slaFeatureAvailable), }, apolloProvider, diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue index ae6b72679e1..9a8c4bc5af9 100644 --- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue +++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue @@ -124,7 +124,6 @@ export default { class="col-8 col-md-9 gl-p-0" :label="$options.i18n.webhookUrl.label" label-for="url" - label-class="label-bold" > <gl-form-input-group id="url" data-testid="webhook-url" readonly :value="webhookUrl"> <template #append> diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index 4339021d9a0..4a1bca110fd 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -301,7 +301,7 @@ export default { " @hide="resetDropdown" > - <gl-search-box-by-type v-model.trim="searchTerm" class="gl-m-3" /> + <gl-search-box-by-type v-model.trim="searchTerm" /> <gl-loading-icon v-if="isFetching" /> diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue index 5ee917573ce..0fa5585e858 100644 --- a/app/assets/javascripts/milestones/project_milestone_combobox.vue +++ b/app/assets/javascripts/milestones/project_milestone_combobox.vue @@ -205,7 +205,6 @@ export default { <gl-search-box-by-type ref="searchBox" v-model.trim="searchQuery" - class="gl-m-3" :placeholder="this.$options.translations.searchMilestones" @input="onSearchBoxInput" @keydown.enter.prevent="onSearchBoxEnter" diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index e468728a954..0f6a9ce3814 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -192,7 +192,7 @@ export default { > <div class="d-flex flex-column overflow-hidden"> <gl-dropdown-section-header>{{ __('Environment') }}</gl-dropdown-section-header> - <gl-search-box-by-type class="gl-m-3" @input="debouncedEnvironmentsSearch" /> + <gl-search-box-by-type @input="debouncedEnvironmentsSearch" /> <gl-loading-icon v-if="environmentsLoading" :inline="true" /> <div v-else class="flex-fill overflow-auto"> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 932efeaaf0e..1a349aa154a 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -80,11 +80,7 @@ export default { > <div class="d-flex flex-column overflow-hidden"> <gl-dropdown-section-header>{{ __('Dashboard') }}</gl-dropdown-section-header> - <gl-search-box-by-type - ref="monitorDashboardsDropdownSearch" - v-model="searchTerm" - class="gl-m-3" - /> + <gl-search-box-by-type ref="monitorDashboardsDropdownSearch" v-model="searchTerm" /> <div class="flex-fill overflow-auto"> <gl-dropdown-item diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index 1cec08b93bd..b05cf080aea 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -237,7 +237,6 @@ export default { <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search branches and tags')" - class="gl-p-2" /> <gl-dropdown-item v-for="(ref, index) in filteredRefs" diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue index 2204ec3cbe7..3bc772fe60a 100644 --- a/app/assets/javascripts/projects/commits/components/author_select.vue +++ b/app/assets/javascripts/projects/commits/components/author_select.vue @@ -119,7 +119,6 @@ export default { <gl-dropdown-divider /> <gl-search-box-by-type v-model.trim="authorInput" - class="gl-m-3" :placeholder="__('Search')" @input="searchAuthors" /> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 85b123530b5..0084450c9b0 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -139,7 +139,6 @@ export default { <gl-search-box-by-type ref="searchBox" v-model.trim="query" - class="gl-m-3" :placeholder="i18n.searchPlaceholder" @input="onSearchBoxInput" @keydown.enter.prevent="onSearchBoxEnter" diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue index 19625b37f0e..ab2553265a2 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue @@ -123,7 +123,7 @@ export default { }; </script> <template> - <div class="form-group file-editor"> + <div class="form-group"> <label :for="firstInputId">{{ s__('Snippets|Files') }}</label> <snippet-blob-edit v-for="(blobId, index) in blobIds" diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 0636d79e6f2..3521c1a105f 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -42,6 +42,7 @@ const populateUserInfo = user => { bio: userData.bio, bioHtml: sanitize(userData.bio_html), workInformation: userData.work_information, + websiteUrl: userData.website_url, loaded: true, }); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index 157d6d60290..e3c0b7935d7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -72,12 +72,7 @@ export default { css-class="deploy-link js-deploy-url inline" /> <gl-dropdown size="small" class="js-mr-wigdet-deployment-dropdown"> - <gl-search-box-by-type - v-model.trim="searchTerm" - v-autofocusonshow - autofocus - class="gl-m-3" - /> + <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> <gl-dropdown-item v-for="change in filteredChanges" :key="change.path" diff --git a/app/assets/javascripts/vue_shared/components/editor_lite.vue b/app/assets/javascripts/vue_shared/components/editor_lite.vue index 98889a0dced..bc3a9ee45f8 100644 --- a/app/assets/javascripts/vue_shared/components/editor_lite.vue +++ b/app/assets/javascripts/vue_shared/components/editor_lite.vue @@ -57,6 +57,9 @@ export default { fileName(newVal) { this.editor.updateModelLanguage(newVal); }, + value(newVal) { + this.editor.setValue(newVal); + }, }, mounted() { this.editor = initEditorLite({ @@ -83,9 +86,12 @@ export default { }; </script> <template> - <div class="file-content code"> - <div id="editor" ref="editor" data-editor-loading @editor-ready="$emit('editor-ready')"> - <pre class="editor-loading-content">{{ value }}</pre> - </div> + <div + :id="`editor-lite-${fileGlobalId}`" + ref="editor" + data-editor-loading + @editor-ready="$emit('editor-ready')" + > + <pre class="editor-loading-content">{{ value }}</pre> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js new file mode 100644 index 00000000000..b7768cfa5b9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js @@ -0,0 +1,21 @@ +import { __ } from '~/locale'; + +export const tdClass = + 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; +export const thClass = 'gl-hover-bg-blue-50'; +export 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'; + +export const defaultPageSize = 20; + +export const initialPaginationState = { + page: 1, + prevPageCursor: '', + nextPageCursor: '', + firstPageSize: defaultPageSize, + lastPageSize: null, +}; + +export const defaultI18n = { + searchPlaceholder: __('Search or filter results…'), +}; diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue new file mode 100644 index 00000000000..8e85d93e6d1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -0,0 +1,313 @@ +<script> +import { GlAlert, GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; +import Api from '~/api'; +import Tracking from '~/tracking'; +import { __ } from '~/locale'; +import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; +import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; +import { isAny } from './utils'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; + +export default { + defaultI18n, + components: { + GlAlert, + GlBadge, + GlPagination, + GlTabs, + GlTab, + FilteredSearchBar, + }, + inject: { + projectPath: { + default: '', + }, + textQuery: { + default: '', + }, + assigneeUsernameQuery: { + default: '', + }, + authorUsernameQuery: { + default: '', + }, + }, + props: { + items: { + type: Array, + required: true, + }, + itemsCount: { + type: Object, + required: false, + default: () => {}, + }, + pageInfo: { + type: Object, + required: false, + default: () => {}, + }, + statusTabs: { + type: Array, + required: true, + }, + showItems: { + type: Boolean, + required: false, + default: true, + }, + showErrorMsg: { + type: Boolean, + required: true, + }, + trackViewsOptions: { + type: Object, + required: true, + }, + i18n: { + type: Object, + required: true, + }, + serverErrorMessage: { + type: String, + required: false, + default: '', + }, + filterSearchKey: { + type: String, + required: true, + }, + filterSearchTokens: { + type: Array, + required: false, + default: () => ['author_username', 'assignee_username'], + }, + }, + data() { + return { + searchTerm: this.textQuery, + authorUsername: this.authorUsernameQuery, + assigneeUsername: this.assigneeUsernameQuery, + filterParams: {}, + pagination: initialPaginationState, + filteredByStatus: '', + statusFilter: '', + }; + }, + computed: { + defaultTokens() { + return [ + { + type: 'author_username', + icon: 'user', + title: __('Author'), + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], + fetchPath: this.projectPath, + fetchAuthors: Api.projectUsers.bind(Api), + }, + { + type: 'assignee_username', + icon: 'user', + title: __('Assignee'), + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], + fetchPath: this.projectPath, + fetchAuthors: Api.projectUsers.bind(Api), + }, + ]; + }, + filteredSearchTokens() { + return this.defaultTokens.filter(({ type }) => this.filterSearchTokens.includes(type)); + }, + filteredSearchValue() { + const value = []; + + if (this.authorUsername) { + value.push({ + type: 'author_username', + value: { data: this.authorUsername }, + }); + } + + if (this.assigneeUsername) { + value.push({ + type: 'assignee_username', + value: { data: this.assigneeUsername }, + }); + } + + if (this.searchTerm) { + value.push(this.searchTerm); + } + + return value; + }, + itemsForCurrentTab() { + return this.itemsCount?.[this.filteredByStatus.toLowerCase()] ?? 0; + }, + showPaginationControls() { + return Boolean(this.pageInfo?.hasNextPage || this.pageInfo?.hasPreviousPage); + }, + previousPage() { + return Math.max(this.pagination.page - 1, 0); + }, + nextPage() { + const nextPage = this.pagination.page + 1; + return nextPage > Math.ceil(this.itemsForCurrentTab / defaultPageSize) ? null : nextPage; + }, + }, + mounted() { + this.trackPageViews(); + }, + methods: { + filterItemsByStatus(tabIndex) { + this.resetPagination(); + const { filters, status } = this.statusTabs[tabIndex]; + this.statusFilter = filters; + this.filteredByStatus = status; + + this.$emit('tabs-changed', { filters, status }); + }, + handlePageChange(page) { + const { startCursor, endCursor } = this.pageInfo; + + if (page > this.pagination.page) { + this.pagination = { + ...initialPaginationState, + nextPageCursor: endCursor, + page, + }; + } else { + this.pagination = { + lastPageSize: defaultPageSize, + firstPageSize: null, + prevPageCursor: startCursor, + nextPageCursor: '', + page, + }; + } + + this.$emit('page-changed', this.pagination); + }, + resetPagination() { + this.pagination = initialPaginationState; + this.$emit('page-changed', this.pagination); + }, + handleFilterItems(filters) { + this.resetPagination(); + const filterParams = { authorUsername: '', assigneeUsername: '', search: '' }; + + filters.forEach(filter => { + if (typeof filter === 'object') { + switch (filter.type) { + case 'author_username': + filterParams.authorUsername = isAny(filter.value.data); + break; + case 'assignee_username': + filterParams.assigneeUsername = isAny(filter.value.data); + break; + case 'filtered-search-term': + if (filter.value.data !== '') filterParams.search = filter.value.data; + break; + default: + break; + } + } + }); + + this.filterParams = filterParams; + this.updateUrl(); + this.searchTerm = filterParams?.search; + this.authorUsername = filterParams?.authorUsername; + this.assigneeUsername = filterParams?.assigneeUsername; + + this.$emit('filters-changed', { + searchTerm: this.searchTerm, + authorUsername: this.authorUsername, + assigneeUsername: this.assigneeUsername, + }); + }, + updateUrl() { + const { authorUsername, assigneeUsername, search } = this.filterParams || {}; + + const params = { + ...(authorUsername !== '' && { author_username: authorUsername }), + ...(assigneeUsername !== '' && { assignee_username: assigneeUsername }), + ...(search !== '' && { search }), + }; + + updateHistory({ + url: setUrlParams(params, window.location.href, true), + title: document.title, + replace: true, + }); + }, + trackPageViews() { + const { category, action } = this.trackViewsOptions; + Tracking.event(category, action); + }, + }, +}; +</script> +<template> + <div class="incident-management-list"> + <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="$emit('error-alert-dismissed')"> + <!-- eslint-disable-next-line vue/no-v-html --> + <p v-html="serverErrorMessage || i18n.errorMsg"></p> + </gl-alert> + + <div + class="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="filterItemsByStatus"> + <gl-tab v-for="tab in statusTabs" :key="tab.status" :data-testid="tab.status"> + <template #title> + <span>{{ tab.title }}</span> + <gl-badge v-if="itemsCount" pill size="sm" class="gl-tab-counter-badge"> + {{ itemsCount[tab.status.toLowerCase()] }} + </gl-badge> + </template> + </gl-tab> + </gl-tabs> + + <slot name="header-actions"></slot> + </div> + + <div class="filtered-search-wrapper"> + <filtered-search-bar + :namespace="projectPath" + :search-input-placeholder="$options.defaultI18n.searchPlaceholder" + :tokens="filteredSearchTokens" + :initial-filter-value="filteredSearchValue" + initial-sortby="created_desc" + :recent-searches-storage-key="filterSearchKey" + class="row-content-block" + @onFilter="handleFilterItems" + /> + </div> + + <h4 class="gl-display-block d-md-none my-3"> + <slot name="title"></slot> + </h4> + + <slot v-if="showItems" name="table"></slot> + + <gl-pagination + v-if="showPaginationControls" + :value="pagination.page" + :prev-page="previousPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="handlePageChange" + /> + + <slot v-if="!showItems" name="emtpy-state"></slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js new file mode 100644 index 00000000000..7de4263acbb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js @@ -0,0 +1,11 @@ +import { __ } from '~/locale'; + +/** + * Return a empty string when passed a value of 'Any' + * + * @param {String} value + * @returns {String} + */ +export const isAny = value => { + return value === __('Any') ? '' : value; +}; diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue index 135b9842cbf..ac222b22112 100644 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue @@ -82,7 +82,7 @@ export default { <gl-icon name="chevron-down" /> </template> - <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus class="gl-m-3" /> + <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> <gl-deprecated-dropdown-item v-for="timezone in filteredResults" :key="timezone.formattedTimezone" diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 6aaff000845..3f5738b2b93 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -1,16 +1,27 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlPopover, GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlIcon } from '@gitlab/ui'; +import { + GlPopover, + GlLink, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlIcon, +} from '@gitlab/ui'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; const MAX_SKELETON_LINES = 4; +const SECURITY_BOT_USER_DATA = { + username: 'GitLab-Security-Bot', + name: 'GitLab Security Bot', +}; + export default { name: 'UserPopover', maxSkeletonLines: MAX_SKELETON_LINES, components: { GlIcon, + GlLink, GlPopover, GlSkeletonLoading, UserAvatarImage, @@ -43,6 +54,15 @@ export default { userIsLoading() { return !this.user?.loaded; }, + isSecurityBot() { + const { username, name, websiteUrl = '' } = this.user; + return ( + gon.features?.securityAutoFix && + username === SECURITY_BOT_USER_DATA.username && + name === SECURITY_BOT_USER_DATA.name && + websiteUrl.length + ); + }, }, }; </script> @@ -89,6 +109,12 @@ export default { <div v-if="statusHtml" class="js-user-status gl-mt-3"> <span v-html="statusHtml"></span> </div> + <div v-if="isSecurityBot" class="gl-text-blue-500"> + <gl-icon name="question" /> + <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl"> + {{ sprintf(__('Learn more about %{username}'), { username: user.name }) }} + </gl-link> + </div> </template> </div> </div> |