summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md12
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_empty_state.vue31
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue63
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue450
-rw-r--r--app/assets/javascripts/alert_management/components/alert_status.vue8
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue6
-rw-r--r--app/assets/javascripts/alert_management/constants.js2
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql4
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql4
-rw-r--r--app/assets/javascripts/alert_management/list.js35
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue22
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue120
-rw-r--r--app/assets/javascripts/boards/index.js9
-rw-r--r--app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql15
-rw-r--r--app/assets/javascripts/boards/stores/actions.js26
-rw-r--r--app/assets/javascripts/boards/stores/getters.js2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue2
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue1
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue508
-rw-r--r--app/assets/javascripts/incidents/constants.js11
-rw-r--r--app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql4
-rw-r--r--app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql4
-rw-r--r--app/assets/javascripts/incidents/list.js8
-rw-r--r--app/assets/javascripts/incidents_settings/components/pagerduty_form.vue1
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue2
-rw-r--r--app/assets/javascripts/milestones/project_milestone_combobox.vue1
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue6
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue1
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue1
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue1
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue2
-rw-r--r--app/assets/javascripts/user_popovers.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/editor_lite.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue313
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue28
-rw-r--r--app/assets/stylesheets/framework/common.scss7
-rw-r--r--app/assets/stylesheets/framework/editor-lite.scss6
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss26
-rw-r--r--app/assets/stylesheets/pages/incident_management_list.scss4
-rw-r--r--app/controllers/concerns/show_inherited_labels_checker.rb2
-rw-r--r--app/helpers/boards_helper.rb21
-rw-r--r--app/helpers/labels_helper.rb4
-rw-r--r--app/helpers/projects/alert_management_helper.rb4
-rw-r--r--app/helpers/projects/incidents_helper.rb4
-rw-r--r--app/services/ci/create_pipeline_service.rb2
-rw-r--r--app/views/admin/users/_user_detail.html.haml2
-rw-r--r--app/views/projects/commit/_commit_box.html.haml6
-rw-r--r--app/views/shared/_label_row.html.haml4
-rw-r--r--app/views/shared/_new_project_item_select.html.haml8
-rw-r--r--app/views/users/_overview.html.haml31
-rw-r--r--app/views/users/show.html.haml83
-rw-r--r--changelogs/unreleased/206929-fix-workflow-rules-variable-access.yml5
-rw-r--r--changelogs/unreleased/241990-default-show_inherited_labels-feature-flag-to-true.yml5
-rw-r--r--changelogs/unreleased/241990-show-all-inherited-labels-in-subgroups.yml5
-rw-r--r--changelogs/unreleased/patch-migration.yml5
-rw-r--r--changelogs/unreleased/revert-42465-and-42343.yml5
-rw-r--r--changelogs/unreleased/sh-improve-pre-receive-error-ff-merge-message.yml5
-rw-r--r--config/feature_categories.yml2
-rw-r--r--config/feature_flags/development/security_auto_fix.yml7
-rw-r--r--config/feature_flags/development/show_inherited_labels.yml4
-rw-r--r--doc/.vale/gitlab/spelling-exceptions.txt1
-rw-r--r--doc/administration/job_artifacts.md2
-rw-r--r--doc/ci/yaml/README.md54
-rw-r--r--doc/gitlab-basics/create-branch.md2
-rw-r--r--doc/user/application_security/dependency_scanning/index.md3
-rw-r--r--doc/user/clusters/cost_management.md2
-rw-r--r--doc/user/project/labels.md4
-rw-r--r--doc/user/project/milestones/burndown_and_burnup_charts.md142
-rw-r--r--doc/user/project/milestones/burndown_charts.md86
-rw-r--r--doc/user/project/milestones/img/burndown_and_burnup_charts_v13_5.pngbin0 -> 55865 bytes
-rw-r--r--doc/user/project/milestones/img/burndown_chart_fixed_v13_5.pngbin0 -> 32250 bytes
-rw-r--r--doc/user/project/milestones/img/burndown_chart_legacy_v13_5.pngbin0 -> 28180 bytes
-rw-r--r--doc/user/project/milestones/img/burndown_chart_v13_5.png (renamed from doc/user/project/milestones/img/burndown_chart.png)bin48403 -> 48403 bytes
-rw-r--r--doc/user/project/milestones/img/burnup_chart_v13_5.pngbin0 -> 29283 bytes
-rw-r--r--doc/user/project/milestones/index.md2
-rw-r--r--lib/gitlab/gon_helper.rb1
-rw-r--r--lib/gitlab/import_export/attribute_cleaner.rb4
-rw-r--r--lib/gitlab/import_export/base/relation_factory.rb4
-rw-r--r--lib/gitlab/import_export/file_importer.rb4
-rw-r--r--lib/gitlab/import_export/project/tree_saver.rb2
-rw-r--r--lib/gitlab/import_export/saver.rb4
-rw-r--r--lib/gitlab/import_export/uploads_manager.rb2
-rw-r--r--lib/gitlab/import_export/version_checker.rb4
-rw-r--r--locale/gitlab.pot23
-rw-r--r--package.json2
-rw-r--r--qa/qa/page/component/new_snippet.rb2
-rw-r--r--qa/qa/page/component/snippet.rb2
-rw-r--r--qa/qa/page/dashboard/snippet/edit.rb5
-rw-r--r--spec/controllers/snippets_controller_spec.rb54
-rw-r--r--spec/features/alert_management/user_searches_alerts_spec.rb18
-rw-r--r--spec/features/alert_management/user_updates_alert_status_spec.rb36
-rw-r--r--spec/features/users/overview_spec.rb70
-rw-r--r--spec/features/users/show_spec.rb42
-rw-r--r--spec/frontend/alert_management/components/alert_management_empty_state_spec.js20
-rw-r--r--spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js50
-rw-r--r--spec/frontend/alert_management/components/alert_management_table_spec.js350
-rw-r--r--spec/frontend/alert_management/components/alert_status_spec.js151
-rw-r--r--spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js4
-rw-r--r--spec/frontend/alert_management/mocks/alerts_provide_config.json13
-rw-r--r--spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap10
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js143
-rw-r--r--spec/frontend/boards/mock_data.js8
-rw-r--r--spec/frontend/boards/stores/actions_spec.js46
-rw-r--r--spec/frontend/incidents/components/incidents_list_spec.js226
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap1
-rw-r--r--spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap4
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap1
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap16
-rw-r--r--spec/frontend/vue_shared/components/editor_lite_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json15
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json (renamed from spec/frontend/incidents/mocks/incidents_filter.json)2
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js350
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js30
-rw-r--r--spec/helpers/boards_helper_spec.rb62
-rw-r--r--spec/helpers/labels_helper_spec.rb30
-rw-r--r--spec/helpers/projects/alert_management_helper_spec.rb4
-rw-r--r--spec/helpers/projects/incidents_helper_spec.rb4
-rw-r--r--spec/services/ci/create_downstream_pipeline_service_spec.rb35
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb106
-rw-r--r--spec/services/ci/pipeline_trigger_service_spec.rb23
-rw-r--r--spec/support/helpers/features/snippet_helpers.rb4
-rw-r--r--spec/views/shared/_label_row.html.haml_spec.rb79
-rw-r--r--yarn.lock8
129 files changed, 2673 insertions, 1733 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de4ab457315..44e2be627ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,18 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
+## 13.4.4 (2020-10-15)
+
+### Fixed (2 changes)
+
+- Fix rollback portion of migration that adds temporary index for container scanning findings. !44593
+- Improve merge error when pre-receive hooks fail in fast-forward merge. !44843
+
+### Other (1 change)
+
+- Revert 42465 and 42343: Expanded collapsed diff files. !43361
+
+
## 13.4.3 (2020-10-06)
### Fixed (3 changes)
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>
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index dbfb034864c..8dbed9c03f2 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -324,15 +324,8 @@ img.emoji {
}
.project-item-select-holder {
- display: inline-block;
- position: relative;
-
.project-item-select {
- position: absolute;
- top: 0;
- right: 0;
min-width: 250px;
- visibility: hidden;
}
}
diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss
index 75d511d7f66..20fea7a82ca 100644
--- a/app/assets/stylesheets/framework/editor-lite.scss
+++ b/app/assets/stylesheets/framework/editor-lite.scss
@@ -1,5 +1,3 @@
-.monaco-editor.gl-editor-lite {
- .line-numbers {
- @include gl-pt-0;
- }
+[id^='editor-lite-'] {
+ height: 500px;
}
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index bbfe65e6eda..7ebc972ac37 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -376,33 +376,11 @@
}
.project-item-select-holder.btn-group {
- display: flex;
- overflow: hidden;
- float: right;
-
- .new-project-item-link {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
.new-project-item-select-button {
- width: 32px;
+ max-width: 44px;
}
}
.empty-state .project-item-select-holder.btn-group {
- float: none;
- justify-content: center;
-
- .btn {
- // overrides styles applied to plain `.empty-state .btn`
- margin: 10px 0;
- max-width: 300px;
- width: auto;
-
- @include media-breakpoint-down(xs) {
- max-width: 250px;
- }
- }
+ max-width: 320px;
}
diff --git a/app/assets/stylesheets/pages/incident_management_list.scss b/app/assets/stylesheets/pages/incident_management_list.scss
index 33012709ac3..c0a1fa72b1f 100644
--- a/app/assets/stylesheets/pages/incident_management_list.scss
+++ b/app/assets/stylesheets/pages/incident_management_list.scss
@@ -8,7 +8,7 @@
@include gl-text-gray-500;
tbody {
- tr {
+ tr:not(.b-table-busy-slot) {
// TODO replace with gitlab/ui utilities: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1791
&:hover {
border-top-style: double;
@@ -132,7 +132,7 @@
}
@include media-breakpoint-down(xs) {
- .incident-management-list-header {
+ .list-header {
flex-direction: column-reverse;
}
diff --git a/app/controllers/concerns/show_inherited_labels_checker.rb b/app/controllers/concerns/show_inherited_labels_checker.rb
index acbea37a62e..9847226f599 100644
--- a/app/controllers/concerns/show_inherited_labels_checker.rb
+++ b/app/controllers/concerns/show_inherited_labels_checker.rb
@@ -6,6 +6,6 @@ module ShowInheritedLabelsChecker
private
def show_inherited_labels?(include_ancestor_groups)
- Feature.enabled?(:show_inherited_labels, @project || @group) || include_ancestor_groups # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ Feature.enabled?(:show_inherited_labels, @project || @group, default_enabled: true) || include_ancestor_groups # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index 4750580e20d..c827fb4dd95 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -18,7 +18,10 @@ module BoardsHelper
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
recent_boards_endpoint: recent_boards_path,
parent: current_board_parent.model_name.param_key,
- group_id: @group&.id
+ group_id: @group&.id,
+ labels_filter_base_path: build_issue_link_base,
+ labels_fetch_path: labels_fetch_path,
+ labels_manage_path: labels_manage_path
}
end
@@ -38,6 +41,22 @@ module BoardsHelper
end
end
+ def labels_fetch_path
+ if board.group_board?
+ group_labels_path(@group, format: :json, only_group_labels: true, include_ancestor_groups: true)
+ else
+ project_labels_path(@project, format: :json, include_ancestor_groups: true)
+ end
+ end
+
+ def labels_manage_path
+ if board.group_board?
+ group_labels_path(@group)
+ else
+ project_labels_path(@project)
+ end
+ end
+
def board_base_url
if board.group_board?
group_boards_url(@group)
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 248d0914871..436af08ce01 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -264,6 +264,10 @@ module LabelsHelper
['issues', 'merge requests']
end
+ def show_labels_full_path?(project, group)
+ project || group&.subgroup?
+ end
+
private
def render_label_link(label_html, link:, title:, dataset:)
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
index c2f0b8854e1..5ce3736c8ef 100644
--- a/app/helpers/projects/alert_management_helper.rb
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -9,7 +9,9 @@ module Projects::AlertManagementHelper
'populating-alerts-help-url' => help_page_url('operations/incident_management/index.md', anchor: 'enable-alert-management'),
'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'),
'user-can-enable-alert-management' => can?(current_user, :admin_operations, project).to_s,
- 'alert-management-enabled' => alert_management_enabled?(project).to_s
+ 'alert-management-enabled' => alert_management_enabled?(project).to_s,
+ 'text-query': params[:search],
+ 'assignee-username-query': params[:assignee_username]
}
end
diff --git a/app/helpers/projects/incidents_helper.rb b/app/helpers/projects/incidents_helper.rb
index 0cac142f2dc..63504cb55b9 100644
--- a/app/helpers/projects/incidents_helper.rb
+++ b/app/helpers/projects/incidents_helper.rb
@@ -10,8 +10,8 @@ module Projects::IncidentsHelper
'issue-path' => project_issues_path(project),
'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg'),
'text-query': params[:search],
- 'author-usernames-query': params[:author_username],
- 'assignee-usernames-query': params[:assignee_username]
+ 'author-username-query': params[:author_username],
+ 'assignee-username-query': params[:assignee_username]
}
end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index e7ede98fea4..5e92c358451 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -14,8 +14,8 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Config::Process,
Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs,
Gitlab::Ci::Pipeline::Chain::Skip,
- Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules,
Gitlab::Ci::Pipeline::Chain::Seed,
+ Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules,
Gitlab::Ci::Pipeline::Chain::Limit::Size,
Gitlab::Ci::Pipeline::Chain::Validate::External,
Gitlab::Ci::Pipeline::Chain::Populate,
diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml
index 3839231cb95..3bafd1cb396 100644
--- a/app/views/admin/users/_user_detail.html.haml
+++ b/app/views/admin/users/_user_detail.html.haml
@@ -15,3 +15,5 @@
.row-second-line.str-truncated-100
= mail_to user.email, user.email, class: 'text-secondary'
+ - unless Feature.disabled?(:security_auto_fix) || !user.internal? || user.website_url.blank?
+ = link_to "(#{_('more information')})", user.website_url
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 6bf2a91694b..86c80f1a8ae 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -22,13 +22,13 @@
.header-action-buttons
- if defined?(@notes_count) && @notes_count > 0
- %span.btn.disabled.btn-grouped.d-none.d-sm-block.gl-mr-3.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
+ %span.btn.disabled.gl-button.btn-icon.d-none.d-sm-inline.gl-mr-3.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
= sprite_icon('comment')
= @notes_count
- = link_to project_tree_path(@project, @commit), class: "btn btn-default gl-mr-3 d-none d-md-inline" do
+ = link_to project_tree_path(@project, @commit), class: "btn gl-button gl-mr-3 d-none d-md-inline" do
#{ _('Browse files') }
.dropdown.inline
- %a.btn.btn-default.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } }
+ %a.btn.gl-button.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } }
%span= _('Options')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 5aee99d434a..9c9ac5f7b2c 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -10,7 +10,7 @@
.description-text.gl-flex-grow-1.gl-overflow-hidden
- if label.description.present?
= markdown_field(label, :description)
- - elsif @project
+ - elsif show_labels_full_path?(@project, @group)
= render 'shared/label_full_path', label: label
%ul.label-links.gl-m-0.gl-p-0.gl-white-space-nowrap
- if show_label_issues_link
@@ -25,6 +25,6 @@
&middot;
%li.js-priority-badge.inline.gl-ml-3
.label-badge.gl-bg-blue-50= _('Prioritized label')
- - if @project && label.description.present?
+ - if label.description.present? && show_labels_full_path?(@project, @group)
.gl-mt-3
= render 'shared/label_full_path', label: label
diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml
index 47fb38d979d..4340a34dc26 100644
--- a/app/views/shared/_new_project_item_select.html.haml
+++ b/app/views/shared/_new_project_item_select.html.haml
@@ -1,7 +1,7 @@
- if any_projects?(@projects)
- .project-item-select-holder.btn-group
- %a.btn.btn-success.new-project-item-link.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } }
+ .project-item-select-holder.btn-group.gl-ml-auto.gl-mr-auto.gl-py-3.gl-relative.gl-display-flex.gl-overflow-hidden
+ %a.btn.gl-button.btn-success.new-project-item-link.block-truncated.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] }, class: "gl-m-0!" }
= loading_icon(color: 'light')
- = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled]
- %button.btn.btn-success.new-project-item-select-button.qa-new-project-item-select-button.gl-p-0
+ = project_select_tag :project_path, class: "project-item-select gl-absolute gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled]
+ %button.btn.dropdown-toggle.btn-success.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button.qa-new-project-item-select-button.gl-p-0.gl-w-100{ class: "gl-m-0!", 'aria-label': _('Toggle project select') }
= sprite_icon('chevron-down')
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index 5b6d1169b4b..294af53e35b 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -1,3 +1,5 @@
+- activity_pane_class = Feature.enabled?(:security_auto_fix) && @user.bot? ? "col-12" : "col-md-12 col-lg-6"
+
.row
.col-12
.calendar-block.gl-mt-3.gl-mb-3
@@ -6,25 +8,26 @@
.spinner.spinner-md
.user-calendar-activities.d-none.d-sm-block
.row
- .col-md-12.col-lg-6
+ %div{ class: activity_pane_class }
- if can?(current_user, :read_cross_project)
.activities-block
.gl-mt-5
- .d-flex.align-items-center.border-bottom
- %h4.flex-grow
- = s_('UserProfile|Activity')
+ .gl-display-flex.gl-align-items-center.gl-border-b-1.gl-border-b-gray-100.gl-border-b-solid
+ %h4.gl-flex-grow-1
+ = Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity')
= link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
.overview-content-list{ data: { href: user_path } }
.center.light.loading
.spinner.spinner-md
- .col-md-12.col-lg-6
- .projects-block
- .gl-mt-5
- .d-flex.align-items-center.border-bottom
- %h4.flex-grow
- = s_('UserProfile|Personal projects')
- = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
- .overview-content-list{ data: { href: user_projects_path } }
- .center.light.loading
- .spinner.spinner-md
+ - unless Feature.enabled?(:security_auto_fix) && @user.bot?
+ .col-md-12.col-lg-6
+ .projects-block
+ .gl-mt-5
+ .gl-display-flex.gl-align-items-center.gl-border-b-1.gl-border-b-gray-100.gl-border-b-solid
+ %h4.gl-flex-grow-1
+ = s_('UserProfile|Personal projects')
+ = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
+ .overview-content-list{ data: { href: user_projects_path } }
+ .center.light.loading
+ .spinner.spinner-md
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 5e212721734..2746a139dd0 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -78,6 +78,8 @@
= sprite_icon('twitter')
- unless @user.website_url.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
+ - if Feature.enabled?(:security_auto_fix) && @user.bot?
+ = sprite_icon('question', css_class: 'gl-text-blue-600')
= link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow'
- unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
@@ -101,26 +103,27 @@
%li.js-activity-tab
= link_to user_activity_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
= s_('UserProfile|Activity')
- - if profile_tab?(:groups)
- %li.js-groups-tab
- = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
- = s_('UserProfile|Groups')
- - if profile_tab?(:contributed)
- %li.js-contributed-tab
- = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
- = s_('UserProfile|Contributed projects')
- - if profile_tab?(:projects)
- %li.js-projects-tab
- = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
- = s_('UserProfile|Personal projects')
- - if profile_tab?(:starred)
- %li.js-starred-tab
- = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do
- = s_('UserProfile|Starred projects')
- - if profile_tab?(:snippets)
- %li.js-snippets-tab
- = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
- = s_('UserProfile|Snippets')
+ - unless Feature.enabled?(:security_auto_fix) && @user.bot?
+ - if profile_tab?(:groups)
+ %li.js-groups-tab
+ = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
+ = s_('UserProfile|Groups')
+ - if profile_tab?(:contributed)
+ %li.js-contributed-tab
+ = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
+ = s_('UserProfile|Contributed projects')
+ - if profile_tab?(:projects)
+ %li.js-projects-tab
+ = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
+ = s_('UserProfile|Personal projects')
+ - if profile_tab?(:starred)
+ %li.js-starred-tab
+ = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do
+ = s_('UserProfile|Starred projects')
+ - if profile_tab?(:snippets)
+ %li.js-snippets-tab
+ = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
+ = s_('UserProfile|Snippets')
%div{ class: container_class }
.tab-content
@@ -136,26 +139,26 @@
.content_list{ data: { href: user_path } }
.loading
.spinner.spinner-md
-
- - if profile_tab?(:groups)
- #groups.tab-pane
- -# This tab is always loaded via AJAX
-
- - if profile_tab?(:contributed)
- #contributed.tab-pane
- -# This tab is always loaded via AJAX
-
- - if profile_tab?(:projects)
- #projects.tab-pane
- -# This tab is always loaded via AJAX
-
- - if profile_tab?(:starred)
- #starred.tab-pane
- -# This tab is always loaded via AJAX
-
- - if profile_tab?(:snippets)
- #snippets.tab-pane
- -# This tab is always loaded via AJAX
+ - unless @user.bot?
+ - if profile_tab?(:groups)
+ #groups.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:contributed)
+ #contributed.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:projects)
+ #projects.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:starred)
+ #starred.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:snippets)
+ #snippets.tab-pane
+ -# This tab is always loaded via AJAX
.loading.hide
.spinner.spinner-md
diff --git a/changelogs/unreleased/206929-fix-workflow-rules-variable-access.yml b/changelogs/unreleased/206929-fix-workflow-rules-variable-access.yml
new file mode 100644
index 00000000000..e14c641faac
--- /dev/null
+++ b/changelogs/unreleased/206929-fix-workflow-rules-variable-access.yml
@@ -0,0 +1,5 @@
+---
+title: Fix workflow:rules not accessing passed-upstream and trigger variables
+merge_request: 44935
+author:
+type: fixed
diff --git a/changelogs/unreleased/241990-default-show_inherited_labels-feature-flag-to-true.yml b/changelogs/unreleased/241990-default-show_inherited_labels-feature-flag-to-true.yml
new file mode 100644
index 00000000000..ad1775cf2cd
--- /dev/null
+++ b/changelogs/unreleased/241990-default-show_inherited_labels-feature-flag-to-true.yml
@@ -0,0 +1,5 @@
+---
+title: Show all inherited labels in projects and subgroups
+merge_request: 45161
+author:
+type: added
diff --git a/changelogs/unreleased/241990-show-all-inherited-labels-in-subgroups.yml b/changelogs/unreleased/241990-show-all-inherited-labels-in-subgroups.yml
new file mode 100644
index 00000000000..079a9b2a458
--- /dev/null
+++ b/changelogs/unreleased/241990-show-all-inherited-labels-in-subgroups.yml
@@ -0,0 +1,5 @@
+---
+title: Show origin path of labels on subgroup labels page
+merge_request: 45040
+author:
+type: added
diff --git a/changelogs/unreleased/patch-migration.yml b/changelogs/unreleased/patch-migration.yml
deleted file mode 100644
index 6335c041a5a..00000000000
--- a/changelogs/unreleased/patch-migration.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix rollback portion of migration that adds temporary index for container scanning findings
-merge_request: 44593
-author:
-type: fixed
diff --git a/changelogs/unreleased/revert-42465-and-42343.yml b/changelogs/unreleased/revert-42465-and-42343.yml
deleted file mode 100644
index 4c7342c9d0d..00000000000
--- a/changelogs/unreleased/revert-42465-and-42343.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: 'Revert 42465 and 42343: Expanded collapsed diff files'
-merge_request: 43361
-author:
-type: other
diff --git a/changelogs/unreleased/sh-improve-pre-receive-error-ff-merge-message.yml b/changelogs/unreleased/sh-improve-pre-receive-error-ff-merge-message.yml
deleted file mode 100644
index af77bfcc41c..00000000000
--- a/changelogs/unreleased/sh-improve-pre-receive-error-ff-merge-message.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Improve merge error when pre-receive hooks fail in fast-forward merge
-merge_request: 44843
-author:
-type: fixed
diff --git a/config/feature_categories.yml b/config/feature_categories.yml
index 97ba7586598..edf7bba27a3 100644
--- a/config/feature_categories.yml
+++ b/config/feature_categories.yml
@@ -49,7 +49,7 @@
- error_tracking
- feature_flags
- foundations
-- fuzz-testing
+- fuzz_testing
- gdk
- geo_replication
- git_lfs
diff --git a/config/feature_flags/development/security_auto_fix.yml b/config/feature_flags/development/security_auto_fix.yml
new file mode 100644
index 00000000000..b97220a1059
--- /dev/null
+++ b/config/feature_flags/development/security_auto_fix.yml
@@ -0,0 +1,7 @@
+---
+name: security_auto_fix
+introduced_by_url:
+rollout_issue_url:
+group: group::composition analysis
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/show_inherited_labels.yml b/config/feature_flags/development/show_inherited_labels.yml
index 3fb26d91227..73ceb07002c 100644
--- a/config/feature_flags/development/show_inherited_labels.yml
+++ b/config/feature_flags/development/show_inherited_labels.yml
@@ -1,7 +1,7 @@
---
name: show_inherited_labels
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42960
-rollout_issue_url:
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/267547
group: group::project management
type: development
-default_enabled: false
+default_enabled: true
diff --git a/doc/.vale/gitlab/spelling-exceptions.txt b/doc/.vale/gitlab/spelling-exceptions.txt
index cf5f6b55caf..60bd4f9cbc7 100644
--- a/doc/.vale/gitlab/spelling-exceptions.txt
+++ b/doc/.vale/gitlab/spelling-exceptions.txt
@@ -61,6 +61,7 @@ buildpacks
bundler
bundlers
burndown
+burnup
cacheable
CAS
CentOS
diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md
index fd658116289..8069b12e0b9 100644
--- a/doc/administration/job_artifacts.md
+++ b/doc/administration/job_artifacts.md
@@ -374,7 +374,7 @@ default artifacts expiration setting, which you can find in the [CI/CD Admin set
> Introduced in GitLab 10.3.
-To disable [the dependencies validation](../ci/yaml/README.md#when-a-dependent-job-will-fail),
+To disable [the dependencies validation](../ci/yaml/README.md#when-a-dependent-job-fails),
you can enable the `ci_disable_validates_dependencies` feature flag from a Rails console.
**In Omnibus installations:**
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index 0e490bbded3..27fbf1843b6 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -181,7 +181,7 @@ To enable or disable the inheritance of all `variables:` or `default:` parameter
- `variables: true` or `variables: false`
To inherit only a subset of `default:` parameters or `variables:`, specify what
-you wish to inherit, and any not listed will **not** be inherited. Use
+you wish to inherit. Anything not listed is **not** inherited. Use
one of the following formats:
```yaml
@@ -344,9 +344,9 @@ workflow:
This example never allows pipelines for schedules or `push` (branches and tags) pipelines,
but does allow pipelines in **all** other cases, *including* merge request pipelines.
-As with `rules` defined in jobs, be careful not to use a configuration that allows
-merge request pipelines and branch pipelines to run at the same time, or you could
-have [duplicate pipelines](#prevent-duplicate-pipelines).
+Be careful not to use a configuration that might run
+merge request pipelines and branch pipelines at the same time. As with `rules` defined in jobs,
+it can cause [duplicate pipelines](#prevent-duplicate-pipelines).
#### `workflow:rules` templates
@@ -358,9 +358,9 @@ for common scenarios. These templates help prevent duplicate pipelines.
The [`Branch-Pipelines` template](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Workflows/Branch-Pipelines.gitlab-ci.yml)
makes your pipelines run for branches and tags.
-Branch pipeline status is displayed within merge requests that use that branch
-as a source, but this pipeline type does not support any features offered by
-[Merge Request Pipelines](../merge_request_pipelines/) like
+Branch pipeline status is displayed within merge requests that use the branch
+as a source. However, this pipeline type does not support any features offered by
+[Merge Request Pipelines](../merge_request_pipelines/), like
[Pipelines for Merge Results](../merge_request_pipelines/#pipelines-for-merged-results)
or [Merge Trains](../merge_request_pipelines/pipelines_for_merged_results/merge_trains/).
Use this template if you are intentionally avoiding those features.
@@ -1529,7 +1529,7 @@ docker build:
script: docker build -t my-image:$CI_COMMIT_REF_SLUG .
rules:
- if: '$VAR == "string value"'
- changes: # Will include the job and set to when:manual if any of the follow paths match a modified file.
+ changes: # Include the job and set to when:manual if any of the follow paths match a modified file.
- Dockerfile
- docker/scripts/*
when: manual
@@ -1730,7 +1730,7 @@ the pipeline if the following is true:
- `(any listed refs are true) AND (any listed variables are true) AND (any listed changes are true) AND (any chosen Kubernetes status matches)`
-In the example below, the `test` job will `only` be created when **all** of the following are true:
+In the example below, the `test` job is `only` created when **all** of the following are true:
- The pipeline has been [scheduled](../pipelines/schedules.md) **or** runs for `master`.
- The `variables` keyword matches.
@@ -1889,7 +1889,7 @@ the `docker build` job is created, but only if changes were made to any of the f
CAUTION: **Warning:**
If you use `only:changes` with [only allow merge requests to be merged if the pipeline succeeds](../../user/project/merge_requests/merge_when_pipeline_succeeds.md#only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds),
-undesired behavior can result if you don't [also use `only:merge_requests`](#using-onlychanges-with-pipelines-for-merge-requests).
+you should [also use `only:merge_requests`](#using-onlychanges-with-pipelines-for-merge-requests). Otherwise it may not work as expected.
You can also use glob patterns to match multiple files in either the root directory
of the repository, or in _any_ directory within the repository. However, they must be wrapped
@@ -1949,13 +1949,9 @@ docker build service one:
- service-one/**/*
```
-In the scenario above, if a merge request is created or updated that changes
-either files in `service-one` directory or the `Dockerfile`, GitLab creates
-and triggers the `docker build service one` job.
-
-Note that if [pipelines for merge requests](../merge_request_pipelines/index.md) is
-combined with `only: [change]`, but `only: [merge_requests]` is omitted, there could be
-unwanted behavior.
+In this scenario, if a merge request changes
+files in the `service-one` directory or the `Dockerfile`, GitLab creates
+the `docker build service one` job.
For example:
@@ -2776,8 +2772,8 @@ The cache is shared between jobs, so if you're using different
paths for different jobs, you should also set a different `cache:key`.
Otherwise cache content can be overwritten.
-The `key` parameter defines the affinity of caching between jobs,
-to have a single cache for all jobs, cache per-job, cache per-branch
+The `key` parameter defines the affinity of caching between jobs.
+You can have a single cache for all jobs, cache per-job, cache per-branch,
or any other way that fits your workflow. This way, you can fine tune caching,
including caching data between different jobs or even different branches.
@@ -3381,7 +3377,7 @@ deploy:
script: make deploy
```
-##### When a dependent job will fail
+##### When a dependent job fails
> Introduced in GitLab 10.3.
@@ -3621,6 +3617,10 @@ Job naming style [was improved](https://gitlab.com/gitlab-org/gitlab/-/issues/23
Use `trigger` to define a downstream pipeline trigger. When GitLab starts a job created
with a `trigger` definition, a downstream pipeline is created.
+Jobs with `trigger` can only use a [limited set of keywords](../multi_project_pipelines.md#limitations).
+For example, you can't run commands with [`script`](#script), [`before_script`](#before_script-and-after_script),
+or [`after_script`](#before_script-and-after_script).
+
You can use this keyword to create two different types of downstream pipelines:
- [Multi-project pipelines](../multi_project_pipelines.md#creating-multi-project-pipelines-from-gitlab-ciyml)
@@ -3772,10 +3772,10 @@ starting, which reduces parallelization.
#### Trigger a pipeline by API call
-Triggers can be used to force a rebuild of a specific branch, tag or commit,
-with an API call when a pipeline gets created using a trigger token.
+To force a rebuild of a specific branch, tag, or commit, you can use an API call
+with a trigger token.
-Not to be confused with the [`trigger`](#trigger) parameter.
+The trigger token is different than the [`trigger`](#trigger) parameter.
[Read more in the triggers documentation.](../triggers/README.md)
@@ -3818,7 +3818,7 @@ step-2:
step-3:
stage: stage3
script:
- - echo "Because step-2 can not be canceled, this step will never be canceled, even though set as interruptible."
+ - echo "Because step-2 can not be canceled, this step can never be canceled, even though it's set as interruptible."
interruptible: true
```
@@ -3837,7 +3837,7 @@ Sometimes running multiple jobs or pipelines at the same time in an environment
can lead to errors during the deployment.
To avoid these errors, the `resource_group` attribute can be used to ensure that
-the runner doesn't run certain jobs simultaneously. Resource groups behave similiar
+the runner doesn't run certain jobs simultaneously. Resource groups behave similar
to semaphores in other programming languages.
When the `resource_group` key is defined for a job in `.gitlab-ci.yml`,
@@ -4003,7 +4003,7 @@ tags. These options cannot be used together, so choose one:
- 'm1'
- 'm2'
- 'm3'
- released_at: '2020-07-15T08:00:00Z' # Optional, will auto generate if not defined, or can use a variable.
+ released_at: '2020-07-15T08:00:00Z' # Optional, is auto generated if not defined, or can use a variable.
```
- To create a release automatically when commits are pushed or merged to the default branch,
@@ -4049,7 +4049,7 @@ tags. These options cannot be used together, so choose one:
- 'm1'
- 'm2'
- 'm3'
- released_at: '2020-07-15T08:00:00Z' # Optional, will auto generate if not defined, or can use a variable.
+ released_at: '2020-07-15T08:00:00Z' # Optional, is auto generated if not defined, or can use a variable.
```
#### Release assets as Generic packages
diff --git a/doc/gitlab-basics/create-branch.md b/doc/gitlab-basics/create-branch.md
index 13f20869b51..9e51a2749a6 100644
--- a/doc/gitlab-basics/create-branch.md
+++ b/doc/gitlab-basics/create-branch.md
@@ -12,7 +12,7 @@ A branch is an independent line of development in a [project](../user/project/in
When you create a new branch (in your [terminal](start-using-git.md) or with
[the web interface](../user/project/repository/web_editor.md#create-a-new-branch)),
you are creating a snapshot of a certain branch, usually the main `master` branch,
-at it's current state. From there, you can start to make your own changes without
+at its current state. From there, you can start to make your own changes without
affecting the main codebase. The history of your changes will be tracked in your branch.
When your changes are ready, you then merge them into the rest of the codebase with a
diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md
index 9290c51a8b8..b90bb37c60f 100644
--- a/doc/user/application_security/dependency_scanning/index.md
+++ b/doc/user/application_security/dependency_scanning/index.md
@@ -67,7 +67,7 @@ The following languages and dependency managers are supported:
| [npm](https://www.npmjs.com/), [yarn](https://classic.yarnpkg.com/en/) | JavaScript | `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium), [Retire.js](https://retirejs.github.io/retire.js/) |
| [NuGet](https://www.nuget.org/) 4.9+ | .NET, C# | [`packages.lock.json`](https://docs.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#enabling-lock-file) | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
| [setuptools](https://setuptools.readthedocs.io/en/latest/), [pip](https://pip.pypa.io/en/stable/), [Pipenv](https://pipenv.pypa.io/en/latest/) | Python | `setup.py`, `requirements.txt`, `requirements.pip`, `requires.txt`, `Pipfile` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
-| [sbt](https://www.scala-sbt.org/) | Scala | `build.sbt` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
+| [sbt](https://www.scala-sbt.org/) 1.2 and below ([Ivy](http://ant.apache.org/ivy/)) | Scala | `build.sbt` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
Plans are underway for supporting the following languages, dependency managers, and dependency files. For details, see the issue link for each.
@@ -75,6 +75,7 @@ Plans are underway for supporting the following languages, dependency managers,
| ------------------- | --------- | --------------- | ------------ |
| [Pipenv](https://pipenv.pypa.io/en/latest/) | Python | `Pipfile.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | [GitLab#11756](https://gitlab.com/gitlab-org/gitlab/-/issues/11756) |
| [Poetry](https://python-poetry.org/) | Python | `poetry.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | [GitLab#7006](https://gitlab.com/gitlab-org/gitlab/-/issues/7006) |
+| [sbt](https://www.scala-sbt.org/) 1.3+ ([Coursier](https://get-coursier.io/))| Scala | `build.sbt` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | [GitLab#249526](https://gitlab.com/gitlab-org/gitlab/-/issues/249526) |
## Contribute your scanner
diff --git a/doc/user/clusters/cost_management.md b/doc/user/clusters/cost_management.md
index c4a2d233ee3..c987a1c21bc 100644
--- a/doc/user/clusters/cost_management.md
+++ b/doc/user/clusters/cost_management.md
@@ -66,7 +66,7 @@ Metrics contain both instance and node labels. The instance label will be deprec
- `container_gpu_allocation` - Average number of GPUs requested over the previous minute.
- `container_memory_allocation_bytes` - Average bytes of RAM requested/used over the previous minute.
- `pod_pvc_allocation` - Bytes provisioned for a PVC attached to a pod.
-- `pv_hourly_cost` - Hourly cost per GP on a persistent volume.
+- `pv_hourly_cost` - Hourly cost per GB on a persistent volume.
Some examples are provided in the
[`kubecost-cost-model` repository](https://gitlab.com/gitlab-examples/kubecost-cost-model/-/blob/master/PROMETHEUS.md#example-queries).
diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md
index bfc37977c9b..db1a8d7d5a2 100644
--- a/doc/user/project/labels.md
+++ b/doc/user/project/labels.md
@@ -57,9 +57,11 @@ and edit labels.
### Project labels
+> Showing all inherited labels [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/241990) in 13.5.
+
To view the project labels list, navigate to the project and click **Issues > Labels**.
The list includes all labels that are defined at the project level, as well as all
-labels inherited from the immediate parent group.
+labels defined by its ancestor groups.
For each label, you can see the project or group path from where it was created.
You can filter the list by entering a search query at the top and clicking search (**{search}**).
diff --git a/doc/user/project/milestones/burndown_and_burnup_charts.md b/doc/user/project/milestones/burndown_and_burnup_charts.md
new file mode 100644
index 00000000000..190ba2a1e7d
--- /dev/null
+++ b/doc/user/project/milestones/burndown_and_burnup_charts.md
@@ -0,0 +1,142 @@
+---
+type: reference
+stage: Plan
+group: Project Management
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
+# Burndown and burnup charts **(STARTER)**
+
+[Burndown](#burndown-charts) and [burnup](#burnup-charts) charts show the progress of completing a milestone.
+
+![burndown and burnup chart](img/burndown_and_burnup_charts_v13_5.png)
+
+## Burndown charts
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1540) in [GitLab Starter](https://about.gitlab.com/pricing/) 9.1 for project milestones.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5354) in [GitLab Premium](https://about.gitlab.com/pricing/) 10.8 for group milestones.
+> - [Added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/6495) to [GitLab Starter](https://about.gitlab.com/pricing/) 11.2 for group milestones.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/6903) [fixed burndown charts](#fixed-burndown-charts) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.5.
+
+Burndown charts show the number of issues over the course of a milestone.
+
+![burndown chart](img/burndown_chart_v13_5.png)
+
+At a glance, you see the current state for the completion a given milestone.
+Without them, you would have to organize the data from the milestone and plot it
+yourself to have the same sense of progress.
+
+GitLab plots it for you and presents it in a clear and beautiful chart.
+
+<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
+For an overview, check the video demonstration on [Mapping work versus time with burndown charts](https://www.youtube.com/watch?v=zJU2MuRChzs).
+
+To view a project's burndown chart:
+
+1. In a project, navigate to **Issues > Milestones**.
+1. Select a milestone from the list.
+
+To view a group's burndown chart:
+
+1. In a group, navigate to **Issues > Milestones**.
+1. Select a milestone from the list.
+
+### Use cases for burndown charts
+
+Burndown charts are generally used for tracking and analyzing the completion of
+a milestone. Therefore, their use cases are tied to the
+[use you are assigning your milestone to](index.md).
+
+For example, suppose you lead a team of developers in a large company,
+and you follow this workflow:
+
+- Your company set the goal for the quarter to deliver 10 new features for your app
+ in the upcoming major release.
+- You create a milestone, and remind your team to assign that milestone to every new issue
+ and merge request that's part of the launch of your app.
+- Every week, you open the milestone, visualize the progress, identify the gaps,
+ and help your team to get their work done.
+- Every month, you check in with your supervisor, and show the progress of that milestone
+ from the burndown chart.
+- By the end of the quarter, your team successfully delivered 100% of that milestone, as
+ it was taken care of closely throughout the whole quarter.
+
+### How burndown charts work
+
+A burndown chart is available for every project or group milestone that has been attributed a **start
+date** and a **due date**.
+
+NOTE: **Note:**
+You're able to [promote project](index.md#promoting-project-milestones-to-group-milestones) to group milestones and still see the **burndown chart** for them, respecting license limitations.
+
+The chart indicates the project's progress throughout that milestone (for issues assigned to it).
+
+In particular, it shows how many issues were or are still open for a given day in the
+milestone's corresponding period.
+
+The burndown chart can also be toggled to display the cumulative open issue
+weight for a given day. When using this feature, make sure issue weights have
+been properly assigned, since an open issue with no weight adds zero to the
+cumulative value.
+
+### Fixed burndown charts
+
+For milestones created before GitLab 13.5, burndown charts have an additional toggle to
+switch between Legacy and Fixed views.
+
+| Legacy | Fixed |
+| ----- | ----- |
+| ![Legacy burndown chart, ](img/burndown_chart_legacy_v13_5.png) | ![Fixed burndown chart, showing a jump when a lot of issues were added to the milestone](img/burndown_chart_fixed_v13_5.png) |
+
+**Fixed burndown** charts track the full history of milestone activity, from its creation until the
+milestone expires. After the milestone due date passes, issues removed from the milestone no longer
+affect the chart.
+
+**Legacy burndown** charts track when issues were created and when they were last closed, not their
+full history. For each day, a legacy burndown chart takes the number of open issues and the issues
+created that day, and subtracts the number of issues closed that day.
+Issues that were created and assigned a milestone before its start date (and remain open as of the
+start date) are considered as having been opened on the start date.
+Therefore, when the milestone start date is changed, the number of opened issues on each day may
+change.
+Reopened issues are considered as having been opened on the day after they were last closed.
+
+## Burnup charts
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/6903) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.5.
+
+Burnup charts show the assigned and completed work for a milestone.
+
+![burnup chart](img/burnup_chart_v13_5.png)
+
+To view a project's burnup chart:
+
+1. In a project, navigate to **Issues > Milestones**.
+1. Select a milestone from the list.
+
+To view a group's burnup chart:
+
+1. In a group, navigate to **Issues > Milestones**.
+1. Select a milestone from the list.
+
+### How burnup charts work
+
+Burnup charts have separate lines for total work and completed work. The total line
+shows when scope is reduced or added to a milestone. The completed work is a count
+of issues closed.
+
+Burnup charts can show either the total number of issues or total weight for each
+day of the milestone. Use the toggle above the charts to switch between total
+and weight.
+
+<!-- ## Troubleshooting
+
+Include any troubleshooting steps that you can foresee. If you know beforehand what issues
+one might have when setting this up, or when something is changed, or on upgrading, it's
+important to describe those, too. Think of things that may go wrong and include them here.
+This is important to minimize requests for support, and to avoid doc comments with
+questions that you know someone might ask.
+
+Each scenario can be a third-level heading, e.g. `### Getting error message X`.
+If you have none to add when creating a doc, leave this section in place
+but commented out to help encourage others to add to it in the future. -->
diff --git a/doc/user/project/milestones/burndown_charts.md b/doc/user/project/milestones/burndown_charts.md
index 913904d962b..5aa5534dd37 100644
--- a/doc/user/project/milestones/burndown_charts.md
+++ b/doc/user/project/milestones/burndown_charts.md
@@ -1,87 +1,5 @@
---
-type: reference
-stage: Plan
-group: Project Management
-info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+redirect_to: './burndown_and_burnup_charts.md'
---
-# Burndown Charts **(STARTER)**
-
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1540) in [GitLab Starter](https://about.gitlab.com/pricing/) 9.1 for project milestones.
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5354) in [GitLab Premium](https://about.gitlab.com/pricing/) 10.8 for group milestones.
-> - [Added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/6495) to [GitLab Starter](https://about.gitlab.com/pricing/) 11.2 for group milestones.
-> - Closed or reopened issues prior to GitLab 9.1 won't have a `closed_at`
-> value, so the burndown chart considers them as closed on the milestone
-> `start_date`. In that case, a warning will be displayed.
-
-Burndown Charts are visual representations of the progress of completing a milestone.
-
-![burndown chart](img/burndown_chart.png)
-
-At a glance, you see the current state for the completion a given milestone.
-Without them, you would have to organize the data from the milestone and plot it
-yourself to have the same sense of progress.
-
-GitLab Starter plots it for you and presents it in a clear and beautiful chart.
-
-<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
-For an overview, check the video demonstration on [Mapping work versus time with Burndown Charts](https://www.youtube.com/watch?v=zJU2MuRChzs).
-
-## Use cases
-
-Burndown Charts are generally used for tracking and analyzing the completion of
-a milestone. Therefore, their use cases are tied to the
-[use you are assigning your milestone to](index.md).
-
-For example, suppose you lead a team of developers in a large company,
-and you follow this workflow:
-
-- Your company set the goal for the quarter to deliver 10 new features for your app
- in the upcoming major release.
-- You create a milestone, and remind your team to assign that milestone to every new issue
- and merge request that's part of the launch of your app.
-- Every week, you open the milestone, visualize the progress, identify the gaps,
- and help your team to get their work done.
-- Every month, you check in with your supervisor, and show the progress of that milestone
- from the Burndown Chart.
-- By the end of the quarter, your team successfully delivered 100% of that milestone, as
- it was taken care of closely throughout the whole quarter.
-
-## How it works
-
-A Burndown Chart is available for every project or group milestone that has been attributed a **start
-date** and a **due date**.
-
-Find your project's **Burndown Chart** under **Project > Issues > Milestones**,
-and select a milestone from your current ones, while for group's, access the **Groups** dashboard,
-select a group, and go through **Issues > Milestones** on the sidebar.
-
-NOTE: **Note:**
-You're able to [promote project](index.md#promoting-project-milestones-to-group-milestones) to group milestones and still see the **Burndown Chart** for them, respecting license limitations.
-
-The chart indicates the project's progress throughout that milestone (for issues assigned to it).
-
-In particular, it shows how many issues were or are still open for a given day in the
-milestone's corresponding period.
-
-The Burndown Chart tracks when issues were created and when they were last closed—not their full history. For each day, it takes the number of issues still open and issues created that day and subtracts the number of issues closed that day.
-**Issues that were created and assigned a milestone before its start date—and remain open as of the start date—are considered as having been opened on the start date**. Therefore, when the milestone start date is changed the number of opened issues on each day may change.
-Reopened issues are
-considered as having been opened on the day after they were last closed.
-
-The Burndown Chart can also be toggled to display the cumulative open issue
-weight for a given day. When using this feature, make sure issue weights have
-been properly assigned, since an open issue with no weight adds zero to the
-cumulative value.
-
-<!-- ## Troubleshooting
-
-Include any troubleshooting steps that you can foresee. If you know beforehand what issues
-one might have when setting this up, or when something is changed, or on upgrading, it's
-important to describe those, too. Think of things that may go wrong and include them here.
-This is important to minimize requests for support, and to avoid doc comments with
-questions that you know someone might ask.
-
-Each scenario can be a third-level heading, e.g. `### Getting error message X`.
-If you have none to add when creating a doc, leave this section in place
-but commented out to help encourage others to add to it in the future. -->
+This document was moved to [another location](./burndown_and_burnup_charts.md).
diff --git a/doc/user/project/milestones/img/burndown_and_burnup_charts_v13_5.png b/doc/user/project/milestones/img/burndown_and_burnup_charts_v13_5.png
new file mode 100644
index 00000000000..8d6ba1d4fa7
--- /dev/null
+++ b/doc/user/project/milestones/img/burndown_and_burnup_charts_v13_5.png
Binary files differ
diff --git a/doc/user/project/milestones/img/burndown_chart_fixed_v13_5.png b/doc/user/project/milestones/img/burndown_chart_fixed_v13_5.png
new file mode 100644
index 00000000000..a532bfeeca0
--- /dev/null
+++ b/doc/user/project/milestones/img/burndown_chart_fixed_v13_5.png
Binary files differ
diff --git a/doc/user/project/milestones/img/burndown_chart_legacy_v13_5.png b/doc/user/project/milestones/img/burndown_chart_legacy_v13_5.png
new file mode 100644
index 00000000000..5824fc59ce5
--- /dev/null
+++ b/doc/user/project/milestones/img/burndown_chart_legacy_v13_5.png
Binary files differ
diff --git a/doc/user/project/milestones/img/burndown_chart.png b/doc/user/project/milestones/img/burndown_chart_v13_5.png
index e06b24f9907..e06b24f9907 100644
--- a/doc/user/project/milestones/img/burndown_chart.png
+++ b/doc/user/project/milestones/img/burndown_chart_v13_5.png
Binary files differ
diff --git a/doc/user/project/milestones/img/burnup_chart_v13_5.png b/doc/user/project/milestones/img/burnup_chart_v13_5.png
new file mode 100644
index 00000000000..a850caba348
--- /dev/null
+++ b/doc/user/project/milestones/img/burnup_chart_v13_5.png
Binary files differ
diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md
index 2290a10a35f..8cbed3de1c6 100644
--- a/doc/user/project/milestones/index.md
+++ b/doc/user/project/milestones/index.md
@@ -150,7 +150,7 @@ There are also tabs below these that show the following:
For project milestones in [GitLab Starter](https://about.gitlab.com/pricing/), a [burndown chart](burndown_charts.md) is in the milestone view, showing the progress of completing a milestone.
-![burndown chart](img/burndown_chart.png)
+![burndown chart](img/burndown_chart_v13_5.png)
### Group Burndown Charts **(STARTER)**
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index ef8f338cae3..10660649623 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -46,6 +46,7 @@ module Gitlab
push_frontend_feature_flag(:webperf_experiment, default_enabled: false)
push_frontend_feature_flag(:snippets_binary_blob, default_enabled: false)
push_frontend_feature_flag(:usage_data_api, default_enabled: true)
+ push_frontend_feature_flag(:security_auto_fix, default_enabled: false)
# Startup CSS feature is a special one as it can be enabled by means of cookies and params
gon.push({ features: { 'startupCss' => use_startup_css? } }, true)
diff --git a/lib/gitlab/import_export/attribute_cleaner.rb b/lib/gitlab/import_export/attribute_cleaner.rb
index 018cb36fc58..379a734b19c 100644
--- a/lib/gitlab/import_export/attribute_cleaner.rb
+++ b/lib/gitlab/import_export/attribute_cleaner.rb
@@ -20,8 +20,8 @@ module Gitlab
/\Aremote_\w+_(url|urls|request_header)\Z/ # carrierwave automatically creates these attribute methods for uploads
).freeze
- def self.clean(*args)
- new(*args).clean
+ def self.clean(*args, **kwargs)
+ new(*args, **kwargs).clean
end
def initialize(relation_hash:, relation_class:, excluded_keys: [])
diff --git a/lib/gitlab/import_export/base/relation_factory.rb b/lib/gitlab/import_export/base/relation_factory.rb
index 00c6f570f4f..d60bc79df4c 100644
--- a/lib/gitlab/import_export/base/relation_factory.rb
+++ b/lib/gitlab/import_export/base/relation_factory.rb
@@ -31,8 +31,8 @@ module Gitlab
TOKEN_RESET_MODELS = %i[Project Namespace Group Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze
- def self.create(*args)
- new(*args).create
+ def self.create(*args, **kwargs)
+ new(*args, **kwargs).create
end
def self.relation_class(relation_name)
diff --git a/lib/gitlab/import_export/file_importer.rb b/lib/gitlab/import_export/file_importer.rb
index 081745a49f4..5a6f6e017d2 100644
--- a/lib/gitlab/import_export/file_importer.rb
+++ b/lib/gitlab/import_export/file_importer.rb
@@ -10,8 +10,8 @@ module Gitlab
MAX_RETRIES = 8
IGNORED_FILENAMES = %w(. ..).freeze
- def self.import(*args)
- new(*args).import
+ def self.import(*args, **kwargs)
+ new(*args, **kwargs).import
end
def initialize(importable:, archive_file:, shared:)
diff --git a/lib/gitlab/import_export/project/tree_saver.rb b/lib/gitlab/import_export/project/tree_saver.rb
index 7cca3596da6..80dacf2eb20 100644
--- a/lib/gitlab/import_export/project/tree_saver.rb
+++ b/lib/gitlab/import_export/project/tree_saver.rb
@@ -36,7 +36,7 @@ module Gitlab
end
def exportable
- @project.present(exportable_params)
+ @project.present(**exportable_params)
end
def exportable_params
diff --git a/lib/gitlab/import_export/saver.rb b/lib/gitlab/import_export/saver.rb
index e4724659eff..045ba2495bf 100644
--- a/lib/gitlab/import_export/saver.rb
+++ b/lib/gitlab/import_export/saver.rb
@@ -5,8 +5,8 @@ module Gitlab
class Saver
include Gitlab::ImportExport::CommandLineUtil
- def self.save(*args)
- new(*args).save
+ def self.save(*args, **kwargs)
+ new(*args, **kwargs).save
end
def initialize(exportable:, shared:)
diff --git a/lib/gitlab/import_export/uploads_manager.rb b/lib/gitlab/import_export/uploads_manager.rb
index dca8e3a7449..26e7d2cf765 100644
--- a/lib/gitlab/import_export/uploads_manager.rb
+++ b/lib/gitlab/import_export/uploads_manager.rb
@@ -40,7 +40,7 @@ module Gitlab
def add_upload(upload)
uploader_context = FileUploader.extract_dynamic_path(upload).named_captures.symbolize_keys
- UploadService.new(@project, File.open(upload, 'r'), FileUploader, uploader_context).execute.to_h
+ UploadService.new(@project, File.open(upload, 'r'), FileUploader, **uploader_context).execute.to_h
end
def copy_project_uploads
diff --git a/lib/gitlab/import_export/version_checker.rb b/lib/gitlab/import_export/version_checker.rb
index 4154d4fe775..48f5b558e52 100644
--- a/lib/gitlab/import_export/version_checker.rb
+++ b/lib/gitlab/import_export/version_checker.rb
@@ -3,8 +3,8 @@
module Gitlab
module ImportExport
class VersionChecker
- def self.check!(*args)
- new(*args).check!
+ def self.check!(*args, **kwargs)
+ new(*args, **kwargs).check!
end
def initialize(shared:)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 098222c277b..3e8950ea3f1 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -185,6 +185,11 @@ msgid_plural "%d failed"
msgstr[0] ""
msgstr[1] ""
+msgid "%d failed security job"
+msgid_plural "%d failed security jobs"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d fixed test result"
msgid_plural "%d fixed test results"
msgstr[0] ""
@@ -2815,6 +2820,9 @@ msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
+msgid "An error occurred when removing the label."
+msgstr ""
+
msgid "An error occurred when toggling the notification subscription"
msgstr ""
@@ -15244,6 +15252,9 @@ msgstr ""
msgid "Learn more"
msgstr ""
+msgid "Learn more about %{username}"
+msgstr ""
+
msgid "Learn more about Auto DevOps"
msgstr ""
@@ -27553,6 +27564,9 @@ msgstr ""
msgid "Toggle navigation"
msgstr ""
+msgid "Toggle project select"
+msgstr ""
+
msgid "Toggle sidebar"
msgstr ""
@@ -27964,6 +27978,9 @@ msgstr ""
msgid "Unable to save your changes. Please try again."
msgstr ""
+msgid "Unable to save your preference"
+msgstr ""
+
msgid "Unable to schedule a pipeline to run immediately"
msgstr ""
@@ -28600,6 +28617,9 @@ msgstr ""
msgid "UserProfile|Blocked user"
msgstr ""
+msgid "UserProfile|Bot activity"
+msgstr ""
+
msgid "UserProfile|Contributed projects"
msgstr ""
@@ -31217,6 +31237,9 @@ msgstr ""
msgid "missing"
msgstr ""
+msgid "more information"
+msgstr ""
+
msgid "most recent deployment"
msgstr ""
diff --git a/package.json b/package.json
index 8851f0a60a0..ffe38235d7e 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.5",
"@gitlab/svgs": "1.171.0",
- "@gitlab/ui": "21.28.0",
+ "@gitlab/ui": "21.30.1",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-3",
"@rails/ujs": "^6.0.3-2",
diff --git a/qa/qa/page/component/new_snippet.rb b/qa/qa/page/component/new_snippet.rb
index ff9ece9ad10..741a3feb73b 100644
--- a/qa/qa/page/component/new_snippet.rb
+++ b/qa/qa/page/component/new_snippet.rb
@@ -79,7 +79,7 @@ module QA
private
def text_area
- find('#editor textarea', visible: false)
+ find('.monaco-editor textarea', visible: false)
end
end
end
diff --git a/qa/qa/page/component/snippet.rb b/qa/qa/page/component/snippet.rb
index 7074d7e7649..9a4b06d8ac7 100644
--- a/qa/qa/page/component/snippet.rb
+++ b/qa/qa/page/component/snippet.rb
@@ -139,7 +139,7 @@ module QA
end
def click_edit_button
- click_element(:snippet_action_button, action: 'Edit')
+ click_element(:snippet_action_button, Page::Dashboard::Snippet::Edit, action: 'Edit')
end
def click_delete_button
diff --git a/qa/qa/page/dashboard/snippet/edit.rb b/qa/qa/page/dashboard/snippet/edit.rb
index 31fc69a04cc..37c0747aea4 100644
--- a/qa/qa/page/dashboard/snippet/edit.rb
+++ b/qa/qa/page/dashboard/snippet/edit.rb
@@ -6,11 +6,10 @@ module QA
module Snippet
class Edit < Page::Base
view 'app/assets/javascripts/snippets/components/edit.vue' do
- element :submit_button
+ element :submit_button, required: true
end
def add_to_file_content(content)
- finished_loading?
text_area.set content
text_area.has_text?(content) # wait for changes to take effect
end
@@ -26,7 +25,7 @@ module QA
private
def text_area
- find('#editor textarea', visible: false)
+ find('.monaco-editor textarea', visible: false)
end
end
end
diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb
index 05d725ee8b6..1ccba7f9114 100644
--- a/spec/controllers/snippets_controller_spec.rb
+++ b/spec/controllers/snippets_controller_spec.rb
@@ -4,6 +4,8 @@ require 'spec_helper'
RSpec.describe SnippetsController do
let_it_be(:user) { create(:user) }
+ let_it_be(:other_user) { create(:user) }
+ let_it_be(:public_snippet) { create(:personal_snippet, :public, :repository, author: user) }
describe 'GET #index' do
let(:base_params) { { username: user.username } }
@@ -12,10 +14,6 @@ RSpec.describe SnippetsController do
it_behaves_like 'paginated collection' do
let(:collection) { Snippet.all }
let(:params) { { username: user.username } }
-
- before do
- create(:personal_snippet, :public, author: user)
- end
end
it 'renders snippets of a user when username is present' do
@@ -97,8 +95,7 @@ RSpec.describe SnippetsController do
end
context 'when signed in user is not the author' do
- let(:other_author) { create(:author) }
- let(:other_personal_snippet) { create(:personal_snippet, :private, author: other_author) }
+ let(:other_personal_snippet) { create(:personal_snippet, :private, author: other_user) }
it 'responds with status 404' do
get :show, params: { id: other_personal_snippet.to_param }
@@ -158,7 +155,7 @@ RSpec.describe SnippetsController do
end
context 'when the personal snippet is public' do
- let_it_be(:personal_snippet) { create(:personal_snippet, :public, :repository, author: user) }
+ let(:personal_snippet) { public_snippet }
context 'when signed in' do
before do
@@ -166,22 +163,22 @@ RSpec.describe SnippetsController do
end
it_behaves_like 'successful response' do
- subject { get :show, params: { id: personal_snippet.to_param } }
+ subject { get :show, params: { id: public_snippet.to_param } }
end
it 'responds with status 200 when embeddable content is requested' do
- get :show, params: { id: personal_snippet.to_param }, format: :js
+ get :show, params: { id: public_snippet.to_param }, format: :js
- expect(assigns(:snippet)).to eq(personal_snippet)
+ expect(assigns(:snippet)).to eq(public_snippet)
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'when not signed in' do
it 'renders the snippet' do
- get :show, params: { id: personal_snippet.to_param }
+ get :show, params: { id: public_snippet.to_param }
- expect(assigns(:snippet)).to eq(personal_snippet)
+ expect(assigns(:snippet)).to eq(public_snippet)
expect(response).to have_gitlab_http_status(:ok)
end
end
@@ -211,37 +208,34 @@ RSpec.describe SnippetsController do
context 'when requesting JSON' do
it 'renders the blob from the repository' do
- personal_snippet = create(:personal_snippet, :public, :repository, author: user)
+ get :show, params: { id: public_snippet.to_param }, format: :json
- get :show, params: { id: personal_snippet.to_param }, format: :json
-
- expect(assigns(:blob)).to eq(personal_snippet.blobs.first)
+ expect(assigns(:blob)).to eq(public_snippet.blobs.first)
end
end
end
describe 'POST #mark_as_spam' do
- let(:snippet) { create(:personal_snippet, :public, author: user) }
-
before do
allow_next_instance_of(Spam::AkismetService) do |instance|
allow(instance).to receive_messages(submit_spam: true)
end
+
stub_application_setting(akismet_enabled: true)
end
def mark_as_spam
admin = create(:admin)
- create(:user_agent_detail, subject: snippet)
+ create(:user_agent_detail, subject: public_snippet)
sign_in(admin)
- post :mark_as_spam, params: { id: snippet.id }
+ post :mark_as_spam, params: { id: public_snippet.id }
end
it 'updates the snippet' do
mark_as_spam
- expect(snippet.reload).not_to be_submittable_as_spam
+ expect(public_snippet.reload).not_to be_submittable_as_spam
end
end
@@ -269,9 +263,7 @@ RSpec.describe SnippetsController do
shared_examples 'CRLF line ending' do
let(:content) { "first line\r\nsecond line\r\nthird line" }
let(:formatted_content) { content.gsub(/\r\n/, "\n") }
- let(:snippet) do
- create(:personal_snippet, :public, :repository, author: user, content: content)
- end
+ let(:snippet) { public_snippet }
before do
allow_next_instance_of(Blob) do |instance|
@@ -340,8 +332,7 @@ RSpec.describe SnippetsController do
end
context 'when signed in user is not the author' do
- let(:other_author) { create(:author) }
- let(:other_personal_snippet) { create(:personal_snippet, :private, author: other_author) }
+ let(:other_personal_snippet) { create(:personal_snippet, :private, author: other_user) }
it 'responds with status 404' do
get :raw, params: { id: other_personal_snippet.to_param }
@@ -385,7 +376,7 @@ RSpec.describe SnippetsController do
end
context 'when the personal snippet is public' do
- let_it_be(:snippet) { create(:personal_snippet, :public, :repository, author: user) }
+ let(:snippet) { public_snippet }
context 'when signed in' do
before do
@@ -429,11 +420,10 @@ RSpec.describe SnippetsController do
end
context 'award emoji on snippets' do
- let(:personal_snippet) { create(:personal_snippet, :public, author: user) }
- let(:another_user) { create(:user) }
+ let(:personal_snippet) { public_snippet }
before do
- sign_in(another_user)
+ sign_in(other_user)
end
describe 'POST #toggle_award_emoji' do
@@ -458,12 +448,10 @@ RSpec.describe SnippetsController do
end
describe 'POST #preview_markdown' do
- let(:snippet) { create(:personal_snippet, :public) }
-
it 'renders json in a correct format' do
sign_in(user)
- post :preview_markdown, params: { id: snippet, text: '*Markdown* text' }
+ post :preview_markdown, params: { id: public_snippet, text: '*Markdown* text' }
expect(json_response.keys).to match_array(%w(body references))
end
diff --git a/spec/features/alert_management/user_searches_alerts_spec.rb b/spec/features/alert_management/user_searches_alerts_spec.rb
index 0a6517101af..568321de025 100644
--- a/spec/features/alert_management/user_searches_alerts_spec.rb
+++ b/spec/features/alert_management/user_searches_alerts_spec.rb
@@ -20,18 +20,12 @@ RSpec.describe 'User searches Alert Management alerts', :js do
end
context 'when a developer displays the alert list and the alert service is enabled they can search an alert' do
- it 'shows the alert table with an alert for a valid search' do
- expect(page).to have_selector('[data-testid="search-icon"]')
-
- find('.gl-search-box-by-type-input').set('Alert')
-
- expect(all('.dropdown-menu-selectable').count).to be(1)
- end
-
- it 'shows the an empty table with an invalid search' do
- find('.gl-search-box-by-type-input').set('invalid search text')
-
- expect(page).not_to have_selector('.dropdown-menu-selectable')
+ it 'shows the incident table with an incident for a valid search filter bar' do
+ expect(page).to have_selector('.filtered-search-wrapper')
+ expect(page).to have_selector('.gl-table')
+ expect(page).to have_css('[data-testid="severityField"]')
+ expect(all('tbody tr').count).to be(1)
+ expect(page).not_to have_selector('.empty-state')
end
end
end
diff --git a/spec/features/alert_management/user_updates_alert_status_spec.rb b/spec/features/alert_management/user_updates_alert_status_spec.rb
new file mode 100644
index 00000000000..8974796662c
--- /dev/null
+++ b/spec/features/alert_management/user_updates_alert_status_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User updates Alert Management status', :js do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:alerts_service) { create(:alerts_service, project: project) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project, status: 'triggered') }
+
+ before_all do
+ project.add_developer(developer)
+ end
+
+ before do
+ sign_in(developer)
+
+ visit project_alert_management_index_path(project)
+ wait_for_requests
+ end
+
+ context 'when a developer+ displays the alerts list and the alert service is enabled they can update an alert status' do
+ it 'shows the alert table with an alert status dropdown' do
+ expect(page).to have_selector('.gl-table')
+ expect(find('.dropdown-menu-selectable')).to have_content('Triggered')
+ end
+
+ it 'updates the alert status' do
+ find('.dropdown-menu-selectable').click
+ find('.dropdown-item', text: 'Acknowledged').click
+ wait_for_requests
+
+ expect(find('.dropdown-menu-selectable')).to have_content('Acknowledged')
+ end
+ end
+end
diff --git a/spec/features/users/overview_spec.rb b/spec/features/users/overview_spec.rb
index 549087e5950..67216b04504 100644
--- a/spec/features/users/overview_spec.rb
+++ b/spec/features/users/overview_spec.rb
@@ -21,15 +21,15 @@ RSpec.describe 'Overview tab on a user profile', :js do
sign_in user
end
- describe 'activities section' do
- shared_context 'visit overview tab' do
- before do
- visit user.username
- page.find('.js-overview-tab a').click
- wait_for_requests
- end
+ shared_context 'visit overview tab' do
+ before do
+ visit user.username
+ page.find('.js-overview-tab a').click
+ wait_for_requests
end
+ end
+ describe 'activities section' do
describe 'user has no activities' do
include_context 'visit overview tab'
@@ -84,14 +84,6 @@ RSpec.describe 'Overview tab on a user profile', :js do
end
describe 'projects section' do
- shared_context 'visit overview tab' do
- before do
- visit user.username
- page.find('.js-overview-tab a').click
- wait_for_requests
- end
- end
-
describe 'user has no personal projects' do
include_context 'visit overview tab'
@@ -158,4 +150,52 @@ RSpec.describe 'Overview tab on a user profile', :js do
end
end
end
+
+ describe 'bot user' do
+ let(:bot_user) { create(:user, user_type: :security_bot) }
+
+ shared_context "visit bot's overview tab" do
+ before do
+ visit bot_user.username
+ page.find('.js-overview-tab a').click
+ wait_for_requests
+ end
+ end
+
+ describe 'feature flag enabled' do
+ before do
+ stub_feature_flags(security_auto_fix: true)
+ end
+
+ include_context "visit bot's overview tab"
+
+ it "activity panel's title is 'Bot activity'" do
+ page.within('.activities-block') do
+ expect(page).to have_text('Bot activity')
+ end
+ end
+
+ it 'does not show projects panel' do
+ expect(page).not_to have_selector('.projects-block')
+ end
+ end
+
+ describe 'feature flag disabled' do
+ before do
+ stub_feature_flags(security_auto_fix: false)
+ end
+
+ include_context "visit bot's overview tab"
+
+ it "activity panel's title is not 'Bot activity'" do
+ page.within('.activities-block') do
+ expect(page).not_to have_text('Bot activity')
+ end
+ end
+
+ it 'shows projects panel' do
+ expect(page).to have_selector('.projects-block')
+ end
+ end
+ end
end
diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb
index b3c8cf8d326..466b7361da9 100644
--- a/spec/features/users/show_spec.rb
+++ b/spec/features/users/show_spec.rb
@@ -182,4 +182,46 @@ RSpec.describe 'User page' do
it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
end
+
+ context 'with a bot user' do
+ let(:user) { create(:user, user_type: :security_bot) }
+
+ describe 'feature flag enabled' do
+ before do
+ stub_feature_flags(security_auto_fix: true)
+ end
+
+ it 'only shows Overview and Activity tabs' do
+ visit(user_path(user))
+
+ page.within '.nav-links' do
+ expect(page).to have_link('Overview')
+ expect(page).to have_link('Activity')
+ expect(page).not_to have_link('Groups')
+ expect(page).not_to have_link('Contributed projects')
+ expect(page).not_to have_link('Personal projects')
+ expect(page).not_to have_link('Snippets')
+ end
+ end
+ end
+
+ describe 'feature flag disabled' do
+ before do
+ stub_feature_flags(security_auto_fix: false)
+ end
+
+ it 'only shows Overview and Activity tabs' do
+ visit(user_path(user))
+
+ page.within '.nav-links' do
+ expect(page).to have_link('Overview')
+ expect(page).to have_link('Activity')
+ expect(page).to have_link('Groups')
+ expect(page).to have_link('Contributed projects')
+ expect(page).to have_link('Personal projects')
+ expect(page).to have_link('Snippets')
+ end
+ end
+ end
+ end
end
diff --git a/spec/frontend/alert_management/components/alert_management_empty_state_spec.js b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js
index 6712282503d..ddb102339cc 100644
--- a/spec/frontend/alert_management/components/alert_management_empty_state_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js
@@ -1,25 +1,17 @@
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue';
+import defaultProvideValues from '../mocks/alerts_provide_config.json';
describe('AlertManagementEmptyState', () => {
let wrapper;
- function mountComponent({
- props = {
- alertManagementEnabled: false,
- userCanEnableAlertManagement: false,
- },
- stubs = {},
- } = {}) {
+ function mountComponent({ provide = {} } = {}) {
wrapper = shallowMount(AlertManagementEmptyState, {
- propsData: {
- enableAlertManagementPath: '/link',
- alertsHelpUrl: '/link',
- emptyAlertSvgPath: 'illustration/path',
- ...props,
+ provide: {
+ ...defaultProvideValues,
+ ...provide,
},
- stubs,
});
}
@@ -42,7 +34,7 @@ describe('AlertManagementEmptyState', () => {
it('show OpsGenie integration state when OpsGenie mcv is true', () => {
mountComponent({
- props: {
+ provide: {
alertManagementEnabled: false,
userCanEnableAlertManagement: false,
opsgenieMvcEnabled: true,
diff --git a/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js
index c36107c28ce..1d79b10a796 100644
--- a/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js
@@ -1,33 +1,18 @@
import { shallowMount } from '@vue/test-utils';
import AlertManagementList from '~/alert_management/components/alert_management_list_wrapper.vue';
-import { trackAlertListViewsOptions } from '~/alert_management/constants';
-import mockAlerts from '../mocks/alerts.json';
-import Tracking from '~/tracking';
+import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue';
+import AlertManagementTable from '~/alert_management/components/alert_management_table.vue';
+import defaultProvideValues from '../mocks/alerts_provide_config.json';
describe('AlertManagementList', () => {
let wrapper;
- function mountComponent({
- props = {
- alertManagementEnabled: false,
- userCanEnableAlertManagement: false,
- },
- data = {},
- stubs = {},
- } = {}) {
+ function mountComponent({ provide = {} } = {}) {
wrapper = shallowMount(AlertManagementList, {
- propsData: {
- projectPath: 'gitlab-org/gitlab',
- enableAlertManagementPath: '/link',
- alertsHelpUrl: '/link',
- populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data',
- emptyAlertSvgPath: 'illustration/path',
- ...props,
+ provide: {
+ ...defaultProvideValues,
+ ...provide,
},
- data() {
- return data;
- },
- stubs,
});
}
@@ -41,18 +26,21 @@ describe('AlertManagementList', () => {
}
});
- describe('Snowplow tracking', () => {
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
+ describe('Alert List Wrapper', () => {
+ it('should show the empty state when alerts are not enabled', () => {
+ expect(wrapper.find(AlertManagementEmptyState).exists()).toBe(true);
+ expect(wrapper.find(AlertManagementTable).exists()).toBe(false);
+ });
+
+ it('should show the alerts table when alerts are enabled', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts } },
+ provide: {
+ alertManagementEnabled: true,
+ },
});
- });
- it('should track alert list page views', () => {
- const { category, action } = trackAlertListViewsOptions;
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
+ expect(wrapper.find(AlertManagementEmptyState).exists()).toBe(false);
+ expect(wrapper.find(AlertManagementTable).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js
index 3aa67614369..f7a629142f9 100644
--- a/spec/frontend/alert_management/components/alert_management_table_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_table_spec.js
@@ -1,26 +1,13 @@
import { mount } from '@vue/test-utils';
-import {
- GlTable,
- GlAlert,
- GlLoadingIcon,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
- GlTabs,
- GlTab,
- GlBadge,
- GlPagination,
- GlSearchBoxByType,
- GlAvatar,
-} from '@gitlab/ui';
-import waitForPromises from 'helpers/wait_for_promises';
+import { GlTable, GlAlert, GlLoadingIcon, GlDropdown, GlIcon, GlAvatar } from '@gitlab/ui';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
import { visitUrl } from '~/lib/utils/url_utility';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import AlertManagementTable from '~/alert_management/components/alert_management_table.vue';
-import { ALERTS_STATUS_TABS, trackAlertStatusUpdateOptions } from '~/alert_management/constants';
-import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import mockAlerts from '../mocks/alerts.json';
-import Tracking from '~/tracking';
+import defaultProvideValues from '../mocks/alerts_provide_config.json';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
@@ -29,26 +16,21 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('AlertManagementTable', () => {
let wrapper;
+ let mock;
const findAlertsTable = () => wrapper.find(GlTable);
const findAlerts = () => wrapper.findAll('table tbody tr');
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
const findStatusDropdown = () => wrapper.find(GlDropdown);
- const findStatusFilterTabs = () => wrapper.findAll(GlTab);
- const findStatusTabs = () => wrapper.find(GlTabs);
- const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
const findDateFields = () => wrapper.findAll(TimeAgo);
- const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
- const findPagination = () => wrapper.find(GlPagination);
- const findSearch = () => wrapper.find(GlSearchBoxByType);
+ const findSearch = () => wrapper.find(FilteredSearchBar);
const findSeverityColumnHeader = () =>
wrapper.find('[data-testid="alert-management-severity-sort"]');
const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0);
const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]');
const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]');
const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]');
- const findAlertError = () => wrapper.find('[data-testid="alert-error"]');
const alertsCount = {
open: 24,
triggered: 20,
@@ -56,26 +38,14 @@ describe('AlertManagementTable', () => {
resolved: 11,
all: 26,
};
- const selectFirstStatusOption = () => {
- findFirstStatusOption().vm.$emit('click');
- return waitForPromises();
- };
-
- function mountComponent({
- props = {
- alertManagementEnabled: false,
- userCanEnableAlertManagement: false,
- },
- data = {},
- loading = false,
- stubs = {},
- } = {}) {
+ function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) {
wrapper = mount(AlertManagementTable, {
- propsData: {
- projectPath: 'gitlab-org/gitlab',
- populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data',
- ...props,
+ provide: {
+ ...defaultProvideValues,
+ alertManagementEnabled: true,
+ userCanEnableAlertManagement: true,
+ ...provide,
},
data() {
return data;
@@ -95,41 +65,21 @@ describe('AlertManagementTable', () => {
});
}
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
- });
-
- describe('Status Filter Tabs', () => {
- beforeEach(() => {
- mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: mockAlerts, alertsCount },
- loading: false,
- stubs: {
- GlTab: true,
- },
- });
- });
-
- it('should display filter tabs with alerts count badge for each status', () => {
- const tabs = findStatusFilterTabs().wrappers;
- const badges = findStatusFilterBadge();
-
- tabs.forEach((tab, i) => {
- const status = ALERTS_STATUS_TABS[i].status.toLowerCase();
- expect(tab.text()).toContain(ALERTS_STATUS_TABS[i].title);
- expect(badges.at(i).text()).toContain(alertsCount[status]);
- });
- });
+ mock.restore();
});
describe('Alerts table', () => {
it('loading state', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: {}, alertsCount: null },
loading: true,
});
@@ -144,8 +94,7 @@ describe('AlertManagementTable', () => {
it('error state', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { errors: ['error'] }, alertsCount: null, hasError: true },
+ data: { alerts: { errors: ['error'] }, alertsCount: null, errored: true },
loading: false,
});
expect(findAlertsTable().exists()).toBe(true);
@@ -161,10 +110,17 @@ describe('AlertManagementTable', () => {
it('empty state', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: [], pageInfo: {} }, alertsCount: { all: 0 }, hasError: false },
+ data: {
+ alerts: { list: [], pageInfo: {} },
+ alertsCount: { all: 0 },
+ errored: false,
+ isErrorAlertDismissed: false,
+ searchTerm: '',
+ assigneeUsername: '',
+ },
loading: false,
});
+
expect(findAlertsTable().exists()).toBe(true);
expect(findAlertsTable().text()).toContain('No alerts to display');
expect(findLoader().exists()).toBe(false);
@@ -178,8 +134,7 @@ describe('AlertManagementTable', () => {
it('has data state', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
expect(findLoader().exists()).toBe(false);
@@ -194,8 +149,7 @@ describe('AlertManagementTable', () => {
it('displays the alert ID and title formatted correctly', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
@@ -205,8 +159,7 @@ describe('AlertManagementTable', () => {
it('displays status dropdown', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
expect(findStatusDropdown().exists()).toBe(true);
@@ -214,8 +167,7 @@ describe('AlertManagementTable', () => {
it('does not display a dropdown status header', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
expect(
@@ -225,27 +177,25 @@ describe('AlertManagementTable', () => {
).toBe(false);
});
- it('shows correct severity icons', () => {
+ it('shows correct severity icons', async () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(GlTable).exists()).toBe(true);
- expect(
- findAlertsTable()
- .find(GlIcon)
- .classes('icon-critical'),
- ).toBe(true);
- });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlTable).exists()).toBe(true);
+ expect(
+ findAlertsTable()
+ .find(GlIcon)
+ .classes('icon-critical'),
+ ).toBe(true);
});
it('renders severity text', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
@@ -258,8 +208,7 @@ describe('AlertManagementTable', () => {
it('renders Unassigned when no assignee(s) present', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
@@ -272,8 +221,7 @@ describe('AlertManagementTable', () => {
it('renders user avatar when assignee present', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
@@ -290,8 +238,7 @@ describe('AlertManagementTable', () => {
it('navigates to the detail page when alert row is clicked', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
@@ -305,8 +252,7 @@ describe('AlertManagementTable', () => {
it('navigates to the detail page in new tab when alert row is clicked with the metaKey', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
@@ -324,8 +270,7 @@ describe('AlertManagementTable', () => {
describe('alert issue links', () => {
beforeEach(() => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
});
@@ -355,7 +300,6 @@ describe('AlertManagementTable', () => {
describe('handle date fields', () => {
it('should display time ago dates when values provided', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: {
alerts: {
list: [
@@ -369,7 +313,7 @@ describe('AlertManagementTable', () => {
],
},
alertsCount,
- hasError: false,
+ errored: false,
},
loading: false,
});
@@ -378,7 +322,6 @@ describe('AlertManagementTable', () => {
it('should not display time ago dates when values not provided', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: {
alerts: [
{
@@ -389,7 +332,7 @@ describe('AlertManagementTable', () => {
},
],
alertsCount,
- hasError: false,
+ errored: false,
},
loading: false,
});
@@ -403,8 +346,7 @@ describe('AlertManagementTable', () => {
it('should highlight the row when alert is new', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: [newAlert] }, alertsCount, hasError: false },
+ data: { alerts: { list: [newAlert] }, alertsCount, errored: false },
loading: false,
});
@@ -417,8 +359,7 @@ describe('AlertManagementTable', () => {
it('should not highlight the row when alert is not new', () => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: [oldAlert] }, alertsCount, hasError: false },
+ data: { alerts: { list: [oldAlert] }, alertsCount, errored: false },
loading: false,
});
@@ -435,10 +376,9 @@ describe('AlertManagementTable', () => {
describe('sorting the alert list by column', () => {
beforeEach(() => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: {
alerts: { list: mockAlerts },
- hasError: false,
+ errored: false,
sort: 'STARTED_AT_DESC',
alertsCount,
},
@@ -458,184 +398,10 @@ describe('AlertManagementTable', () => {
});
});
- describe('updating the alert status', () => {
- const iid = '1527542';
- const mockUpdatedMutationResult = {
- data: {
- updateAlertStatus: {
- errors: [],
- alert: {
- iid,
- status: 'acknowledged',
- },
- },
- },
- };
-
- beforeEach(() => {
- mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
- loading: false,
- });
- });
-
- it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
- findFirstStatusOption().vm.$emit('click');
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: updateAlertStatus,
- variables: {
- iid,
- status: 'TRIGGERED',
- projectPath: 'gitlab-org/gitlab',
- },
- });
- });
-
- describe('when a request fails', () => {
- beforeEach(() => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
- });
-
- it('shows an error', async () => {
- await selectFirstStatusOption();
-
- expect(findAlertError().text()).toContain(
- 'There was an error while updating the status of the alert.',
- );
- });
-
- it('shows an error when triggered a second time', async () => {
- await selectFirstStatusOption();
-
- wrapper.find(GlAlert).vm.$emit('dismiss');
-
- await wrapper.vm.$nextTick();
-
- // Assert that the error has been dismissed in the setup
- expect(findAlertError().exists()).toBe(false);
-
- await selectFirstStatusOption();
-
- expect(findAlertError().exists()).toBe(true);
- });
- });
-
- it('shows an error when response includes HTML errors', async () => {
- const mockUpdatedMutationErrorResult = {
- data: {
- updateAlertStatus: {
- errors: ['<span data-testid="htmlError" />'],
- alert: {
- iid,
- status: 'acknowledged',
- },
- },
- },
- };
-
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationErrorResult);
-
- await selectFirstStatusOption();
-
- expect(findAlertError().exists()).toBe(true);
- expect(
- findAlertError()
- .find('[data-testid="htmlError"]')
- .exists(),
- ).toBe(true);
- });
- });
-
- describe('Snowplow tracking', () => {
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
- mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount },
- loading: false,
- });
- });
-
- it('should track alert status updates', () => {
- Tracking.event.mockClear();
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({});
- findFirstStatusOption().vm.$emit('click');
- const status = findFirstStatusOption().text();
- setImmediate(() => {
- const { category, action, label } = trackAlertStatusUpdateOptions;
- expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status });
- });
- });
- });
-
- describe('Pagination', () => {
- beforeEach(() => {
- mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts, pageInfo: {} }, alertsCount, hasError: false },
- loading: false,
- });
- });
-
- it('does NOT show pagination control when list is smaller than default page size', () => {
- findStatusTabs().vm.$emit('input', 3);
- return wrapper.vm.$nextTick(() => {
- expect(findPagination().exists()).toBe(false);
- });
- });
-
- it('shows pagination control when list is larger than default page size', () => {
- findStatusTabs().vm.$emit('input', 0);
- return wrapper.vm.$nextTick(() => {
- expect(findPagination().exists()).toBe(true);
- });
- });
-
- describe('prevPage', () => {
- it('returns prevPage number', () => {
- findPagination().vm.$emit('input', 3);
-
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.prevPage).toBe(2);
- });
- });
-
- it('returns 0 when it is the first page', () => {
- findPagination().vm.$emit('input', 1);
-
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.prevPage).toBe(0);
- });
- });
- });
-
- describe('nextPage', () => {
- it('returns nextPage number', () => {
- findPagination().vm.$emit('input', 1);
-
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.nextPage).toBe(2);
- });
- });
-
- it('returns `null` when currentPage is already last page', () => {
- findStatusTabs().vm.$emit('input', 3);
- findPagination().vm.$emit('input', 1);
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.nextPage).toBeNull();
- });
- });
- });
- });
-
describe('Search', () => {
beforeEach(() => {
mountComponent({
- props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
- data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
});
@@ -643,13 +409,5 @@ describe('AlertManagementTable', () => {
it('renders the search component', () => {
expect(findSearch().exists()).toBe(true);
});
-
- it('sets the `searchTerm` graphql variable', () => {
- const SEARCH_TERM = 'Simple Alert';
-
- findSearch().vm.$emit('input', SEARCH_TERM);
-
- expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM);
- });
});
});
diff --git a/spec/frontend/alert_management/components/alert_status_spec.js b/spec/frontend/alert_management/components/alert_status_spec.js
new file mode 100644
index 00000000000..f5916b8b265
--- /dev/null
+++ b/spec/frontend/alert_management/components/alert_status_spec.js
@@ -0,0 +1,151 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import { trackAlertStatusUpdateOptions } from '~/alert_management/constants';
+import AlertManagementStatus from '~/alert_management/components/alert_status.vue';
+import updateAlertStatusMutation from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
+import Tracking from '~/tracking';
+import mockAlerts from '../mocks/alerts.json';
+
+const mockAlert = mockAlerts[0];
+
+describe('AlertManagementStatus', () => {
+ let wrapper;
+ const findStatusDropdown = () => wrapper.find(GlDropdown);
+ const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
+
+ const selectFirstStatusOption = () => {
+ findFirstStatusOption().vm.$emit('click');
+
+ return waitForPromises();
+ };
+
+ function mountComponent({ props = {}, loading = false, stubs = {} } = {}) {
+ wrapper = shallowMount(AlertManagementStatus, {
+ propsData: {
+ alert: { ...mockAlert },
+ projectPath: 'gitlab-org/gitlab',
+ isSidebar: false,
+ ...props,
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ queries: {
+ alert: {
+ loading,
+ },
+ },
+ },
+ },
+ stubs,
+ });
+ }
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('updating the alert status', () => {
+ const iid = '1527542';
+ const mockUpdatedMutationResult = {
+ data: {
+ updateAlertStatus: {
+ errors: [],
+ alert: {
+ iid,
+ status: 'acknowledged',
+ },
+ },
+ },
+ };
+
+ beforeEach(() => {
+ mountComponent({});
+ });
+
+ it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
+ findFirstStatusOption().vm.$emit('click');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateAlertStatusMutation,
+ variables: {
+ iid,
+ status: 'TRIGGERED',
+ projectPath: 'gitlab-org/gitlab',
+ },
+ });
+ });
+
+ describe('when a request fails', () => {
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
+ });
+
+ it('emits an error', async () => {
+ await selectFirstStatusOption();
+
+ expect(wrapper.emitted('alert-error')[0]).toEqual([
+ 'There was an error while updating the status of the alert. Please try again.',
+ ]);
+ });
+
+ it('emits an error when triggered a second time', async () => {
+ await selectFirstStatusOption();
+ await wrapper.vm.$nextTick();
+ await selectFirstStatusOption();
+ // Should emit two errors [0,1]
+ expect(wrapper.emitted('alert-error').length > 1).toBe(true);
+ });
+ });
+
+ it('shows an error when response includes HTML errors', async () => {
+ const mockUpdatedMutationErrorResult = {
+ data: {
+ updateAlertStatus: {
+ errors: ['<span data-testid="htmlError" />'],
+ alert: {
+ iid,
+ status: 'acknowledged',
+ },
+ },
+ },
+ };
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationErrorResult);
+
+ await selectFirstStatusOption();
+
+ expect(wrapper.emitted('alert-error').length > 0).toBe(true);
+ expect(wrapper.emitted('alert-error')[0]).toEqual([
+ 'There was an error while updating the status of the alert. <span data-testid="htmlError" />',
+ ]);
+ });
+ });
+
+ describe('Snowplow tracking', () => {
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ mountComponent({});
+ });
+
+ it('should track alert status updates', () => {
+ Tracking.event.mockClear();
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({});
+ findFirstStatusOption().vm.$emit('click');
+ const status = findFirstStatusOption().text();
+ setImmediate(() => {
+ const { category, action, label } = trackAlertStatusUpdateOptions;
+ expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
index e144d473c12..bef4a341985 100644
--- a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
+++ b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { trackAlertStatusUpdateOptions } from '~/alert_management/constants';
import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue';
-import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
+import updateAlertStatusMutation from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
import Tracking from '~/tracking';
import mockAlerts from '../../mocks/alerts.json';
@@ -85,7 +85,7 @@ describe('Alert Details Sidebar Status', () => {
findStatusDropdownItem().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: updateAlertStatus,
+ mutation: updateAlertStatusMutation,
variables: {
iid: '1527542',
status: 'TRIGGERED',
diff --git a/spec/frontend/alert_management/mocks/alerts_provide_config.json b/spec/frontend/alert_management/mocks/alerts_provide_config.json
new file mode 100644
index 00000000000..af543e641bc
--- /dev/null
+++ b/spec/frontend/alert_management/mocks/alerts_provide_config.json
@@ -0,0 +1,13 @@
+{
+ "textQuery": "foo",
+ "authorUsernameQuery": "root",
+ "assigneeUsernameQuery": "root",
+ "projectPath": "gitlab-org/gitlab",
+ "enableAlertManagementPath": "/link",
+ "populatingAlertsHelpUrl": "/link",
+ "emptyAlertSvgPath": "/link",
+ "alertManagementEnabled": false,
+ "userCanEnableAlertManagement": false,
+ "opsgenieMvcTargetUrl": "/link",
+ "opsgenieMvcEnabled": false
+} \ No newline at end of file
diff --git a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
index 7f5374d20fd..fa517ba935d 100644
--- a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
+++ b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
@@ -6,7 +6,7 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
<integrations-list-stub integrations=\\"[object Object],[object Object]\\"></integrations-list-stub>
<gl-form-stub>
<h5 class=\\"gl-font-lg\\">Add new integrations</h5>
- <gl-form-group-stub label-for=\\"integrations\\" label-class=\\"gl-font-weight-bold\\">
+ <gl-form-group-stub label-for=\\"integrations\\">
<div data-testid=\\"alert-settings-description\\" class=\\"gl-mt-5\\">
<p>
<gl-sprintf-stub message=\\"You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.\\"></gl-sprintf-stub>
@@ -17,23 +17,23 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
</div>
<gl-form-select-stub options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"generic\\"></gl-form-select-stub> <span class=\\"gl-text-gray-200\\"><gl-sprintf-stub message=\\"Learn more about our %{linkStart}upcoming integrations%{linkEnd}\\"></gl-sprintf-stub></span>
</gl-form-group-stub>
- <gl-form-group-stub label=\\"Active\\" label-for=\\"activated\\" label-class=\\"gl-font-weight-bold\\">
+ <gl-form-group-stub label=\\"Active\\" label-for=\\"activated\\">
<toggle-button-stub id=\\"activated\\"></toggle-button-stub>
</gl-form-group-stub>
<!---->
- <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\" label-class=\\"gl-font-weight-bold\\">
+ <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\">
<gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-200\\">
</span>
</gl-form-group-stub>
- <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\" label-class=\\"gl-font-weight-bold\\">
+ <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\">
<gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub>
<gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
<gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\">
Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.
</gl-modal-stub>
</gl-form-group-stub>
- <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\" label-class=\\"gl-font-weight-bold\\">
+ <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\">
<gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub>
</gl-form-group-stub>
<gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub>
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js
new file mode 100644
index 00000000000..da000d21f6a
--- /dev/null
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js
@@ -0,0 +1,143 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLabel } from '@gitlab/ui';
+import { TEST_HOST } from 'helpers/test_constants';
+import { labels as TEST_LABELS, mockIssue as TEST_ISSUE } from 'jest/boards/mock_data';
+import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
+import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createStore } from '~/boards/stores';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
+
+const TEST_LABELS_PAYLOAD = TEST_LABELS.map(label => ({ ...label, set: true }));
+const TEST_LABELS_TITLES = TEST_LABELS.map(label => label.title);
+
+describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
+ let wrapper;
+ let store;
+
+ afterEach(() => {
+ wrapper.destroy();
+ store = null;
+ wrapper = null;
+ });
+
+ const createWrapper = ({ labels = [] } = {}) => {
+ store = createStore();
+ store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } };
+ store.state.activeId = TEST_ISSUE.id;
+
+ wrapper = shallowMount(BoardSidebarLabelsSelect, {
+ store,
+ provide: {
+ canUpdate: true,
+ labelsFetchPath: TEST_HOST,
+ labelsManagePath: TEST_HOST,
+ labelsFilterBasePath: TEST_HOST,
+ },
+ stubs: {
+ 'board-editable-item': BoardEditableItem,
+ 'labels-select': '<div></div>',
+ },
+ });
+ };
+
+ const findLabelsSelect = () => wrapper.find({ ref: 'labelsSelect' });
+ const findLabelsTitles = () => wrapper.findAll(GlLabel).wrappers.map(item => item.props('title'));
+ const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
+
+ it('renders "None" when no labels are selected', () => {
+ createWrapper();
+
+ expect(findCollapsed().text()).toBe('None');
+ });
+
+ it('renders labels when set', () => {
+ createWrapper({ labels: TEST_LABELS });
+
+ expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
+ });
+
+ describe('when labels are submitted', () => {
+ beforeEach(async () => {
+ createWrapper();
+
+ jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => TEST_LABELS);
+ findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD);
+ store.state.issues[TEST_ISSUE.id].labels = TEST_LABELS;
+ await wrapper.vm.$nextTick();
+ });
+
+ it('collapses sidebar and renders labels', () => {
+ expect(findCollapsed().isVisible()).toBe(true);
+ expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
+ });
+
+ it('commits change to the server', () => {
+ expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({
+ addLabelIds: TEST_LABELS.map(label => label.id),
+ projectPath: 'gitlab-org/test-subgroup/gitlab-test',
+ removeLabelIds: [],
+ });
+ });
+ });
+
+ describe('when labels are updated over existing labels', () => {
+ const testLabelsPayload = [{ id: 5, set: true }, { id: 7, set: true }];
+ const expectedLabels = [{ id: 5 }, { id: 7 }];
+
+ beforeEach(async () => {
+ createWrapper({ labels: TEST_LABELS });
+
+ jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => expectedLabels);
+ findLabelsSelect().vm.$emit('updateSelectedLabels', testLabelsPayload);
+ await wrapper.vm.$nextTick();
+ });
+
+ it('commits change to the server', () => {
+ expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({
+ addLabelIds: [5, 7],
+ removeLabelIds: [6],
+ projectPath: 'gitlab-org/test-subgroup/gitlab-test',
+ });
+ });
+ });
+
+ describe('when removing individual labels', () => {
+ const testLabel = TEST_LABELS[0];
+
+ beforeEach(async () => {
+ createWrapper({ labels: [testLabel] });
+
+ jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => {});
+ });
+
+ it('commits change to the server', () => {
+ wrapper.find(GlLabel).vm.$emit('close', testLabel);
+
+ expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({
+ removeLabelIds: [getIdFromGraphQLId(testLabel.id)],
+ projectPath: 'gitlab-org/test-subgroup/gitlab-test',
+ });
+ });
+ });
+
+ describe('when the mutation fails', () => {
+ beforeEach(async () => {
+ createWrapper({ labels: TEST_LABELS });
+
+ jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => {
+ throw new Error(['failed mutation']);
+ });
+ findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]);
+ await wrapper.vm.$nextTick();
+ });
+
+ it('collapses sidebar and renders former issue weight', () => {
+ expect(findCollapsed().isVisible()).toBe(true);
+ expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 5776332c499..50c0a85fc70 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -108,13 +108,19 @@ const assignees = [
},
];
-const labels = [
+export const labels = [
{
id: 'gid://gitlab/GroupLabel/5',
title: 'Cosync',
color: '#34ebec',
description: null,
},
+ {
+ id: 'gid://gitlab/GroupLabel/6',
+ title: 'Brock',
+ color: '#e082b6',
+ description: null,
+ },
];
export const rawIssue = {
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 859b95347a6..fb882aaa8d6 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -7,6 +7,7 @@ import {
mockIssue2WithModel,
rawIssue,
mockIssues,
+ labels,
} from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
@@ -526,6 +527,51 @@ describe('addListIssueFailure', () => {
});
});
+describe('setActiveIssueLabels', () => {
+ const state = { issues: { [mockIssue.id]: mockIssue } };
+ const getters = { getActiveIssue: mockIssue };
+ const testLabelIds = labels.map(label => label.id);
+ const input = {
+ addLabelIds: testLabelIds,
+ removeLabelIds: [],
+ projectPath: 'h/b',
+ };
+
+ it('should assign labels on success', done => {
+ jest
+ .spyOn(gqlClient, 'mutate')
+ .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } });
+
+ const payload = {
+ issueId: getters.getActiveIssue.id,
+ prop: 'labels',
+ value: labels,
+ };
+
+ testAction(
+ actions.setActiveIssueLabels,
+ input,
+ { ...state, ...getters },
+ [
+ {
+ type: types.UPDATE_ISSUE_BY_ID,
+ payload,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('throws error if fails', async () => {
+ jest
+ .spyOn(gqlClient, 'mutate')
+ .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } });
+
+ await expect(actions.setActiveIssueLabels({ getters }, input)).rejects.toThrow(Error);
+ });
+});
+
describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog);
});
diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js
index 5b0437d9263..99365a037a4 100644
--- a/spec/frontend/incidents/components/incidents_list_spec.js
+++ b/spec/frontend/incidents/components/incidents_list_spec.js
@@ -1,32 +1,17 @@
import { mount } from '@vue/test-utils';
-import {
- GlAlert,
- GlLoadingIcon,
- GlTable,
- GlAvatar,
- GlPagination,
- GlTab,
- GlTabs,
- GlBadge,
- GlEmptyState,
-} from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlTable, GlAvatar, GlEmptyState } from '@gitlab/ui';
import Tracking from '~/tracking';
import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility';
import IncidentsList from '~/incidents/components/incidents_list.vue';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
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 {
I18N,
- INCIDENT_STATUS_TABS,
TH_CREATED_AT_TEST_ID,
TH_SEVERITY_TEST_ID,
TH_PUBLISHED_TEST_ID,
- trackIncidentCreateNewOptions,
} from '~/incidents/constants';
import mockIncidents from '../mocks/incidents.json';
-import mockFilters from '../mocks/incidents_filter.json';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
@@ -54,15 +39,10 @@ describe('Incidents List', () => {
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
- const findSearch = () => wrapper.find(FilteredSearchBar);
- const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]');
const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]');
+ const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]');
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
- const findPagination = () => wrapper.find(GlPagination);
- const findStatusFilterTabs = () => wrapper.findAll(GlTab);
- const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
- const findStatusTabs = () => wrapper.find(GlTabs);
const findEmptyState = () => wrapper.find(GlEmptyState);
const findSeverity = () => wrapper.findAll(SeverityToken);
const findIncidentSla = () => wrapper.findAll("[data-testid='incident-sla']");
@@ -94,8 +74,8 @@ describe('Incidents List', () => {
publishedAvailable: true,
emptyListSvgPath,
textQuery: '',
- authorUsernamesQuery: '',
- assigneeUsernamesQuery: '',
+ authorUsernameQuery: '',
+ assigneeUsernameQuery: '',
slaFeatureAvailable: true,
...provide,
},
@@ -275,204 +255,10 @@ describe('Incidents List', () => {
expect(findCreateIncidentBtn().exists()).toBe(false);
});
- it('should track alert list page views', async () => {
+ it('should track create new incident button', async () => {
findCreateIncidentBtn().vm.$emit('click');
await wrapper.vm.$nextTick();
- const { category, action } = trackIncidentCreateNewOptions;
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
- });
- });
-
- describe('Pagination', () => {
- beforeEach(() => {
- mountComponent({
- data: {
- incidents: {
- list: mockIncidents,
- pageInfo: { hasNextPage: true, hasPreviousPage: true },
- },
- incidentsCount,
- errored: false,
- },
- loading: false,
- });
- });
-
- it('should render pagination', () => {
- expect(wrapper.find(GlPagination).exists()).toBe(true);
- });
-
- describe('prevPage', () => {
- it('returns prevPage button', async () => {
- findPagination().vm.$emit('input', 3);
-
- await wrapper.vm.$nextTick();
- expect(
- findPagination()
- .findAll('.page-item')
- .at(0)
- .text(),
- ).toBe('Prev');
- });
-
- it('returns prevPage number', async () => {
- findPagination().vm.$emit('input', 3);
-
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.prevPage).toBe(2);
- });
-
- it('returns 0 when it is the first page', async () => {
- findPagination().vm.$emit('input', 1);
-
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.prevPage).toBe(0);
- });
- });
-
- describe('nextPage', () => {
- it('returns nextPage button', async () => {
- findPagination().vm.$emit('input', 3);
-
- await wrapper.vm.$nextTick();
- expect(
- findPagination()
- .findAll('.page-item')
- .at(1)
- .text(),
- ).toBe('Next');
- });
-
- it('returns nextPage number', async () => {
- mountComponent({
- data: {
- incidents: {
- list: [...mockIncidents, ...mockIncidents, ...mockIncidents],
- pageInfo: { hasNextPage: true, hasPreviousPage: true },
- },
- incidentsCount,
- errored: false,
- },
- loading: false,
- });
- findPagination().vm.$emit('input', 1);
-
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.nextPage).toBe(2);
- });
-
- it('returns `null` when currentPage is already last page', async () => {
- findStatusTabs().vm.$emit('input', 1);
- findPagination().vm.$emit('input', 1);
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.nextPage).toBeNull();
- });
- });
-
- describe('Filtered search component', () => {
- beforeEach(() => {
- mountComponent({
- data: {
- incidents: {
- list: mockIncidents,
- pageInfo: { hasNextPage: true, hasPreviousPage: true },
- },
- incidentsCount,
- errored: false,
- },
- loading: false,
- });
- });
-
- it('renders the search component for incidents', () => {
- expect(findSearch().props('searchInputPlaceholder')).toBe('Search or filter results…');
- expect(findSearch().props('tokens')).toEqual([
- {
- type: 'author_username',
- icon: 'user',
- title: 'Author',
- unique: true,
- symbol: '@',
- token: AuthorToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
- fetchPath: '/project/path',
- fetchAuthors: expect.any(Function),
- },
- {
- type: 'assignee_username',
- icon: 'user',
- title: 'Assignees',
- unique: true,
- symbol: '@',
- token: AuthorToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
- fetchPath: '/project/path',
- fetchAuthors: expect.any(Function),
- },
- ]);
- expect(findSearch().props('recentSearchesStorageKey')).toBe('incidents');
- });
-
- it('returns correctly applied filter search values', async () => {
- const searchTerm = 'foo';
- wrapper.setData({
- searchTerm,
- });
-
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]);
- });
-
- it('updates props tied to getIncidents GraphQL query', () => {
- wrapper.vm.handleFilterIncidents(mockFilters);
-
- expect(wrapper.vm.authorUsername).toBe('root');
- expect(wrapper.vm.assigneeUsernames).toEqual('root2');
- expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data);
- });
-
- it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => {
- wrapper.setData({
- authorUsername: 'foo',
- searchTerm: 'bar',
- });
-
- wrapper.vm.handleFilterIncidents([]);
-
- expect(wrapper.vm.authorUsername).toBe('');
- expect(wrapper.vm.searchTerm).toBe('');
- });
- });
-
- describe('Status Filter Tabs', () => {
- beforeEach(() => {
- mountComponent({
- data: { incidents: { list: mockIncidents }, incidentsCount },
- loading: false,
- stubs: {
- GlTab: true,
- },
- });
- });
-
- it('should display filter tabs', () => {
- const tabs = findStatusFilterTabs().wrappers;
-
- tabs.forEach((tab, i) => {
- expect(tab.attributes('data-testid')).toContain(INCIDENT_STATUS_TABS[i].status);
- });
- });
-
- it('should display filter tabs with alerts count badge for each status', () => {
- const tabs = findStatusFilterTabs().wrappers;
- const badges = findStatusFilterBadge();
-
- tabs.forEach((tab, i) => {
- const status = INCIDENT_STATUS_TABS[i].status.toLowerCase();
- expect(tab.attributes('data-testid')).toContain(INCIDENT_STATUS_TABS[i].status);
- expect(badges.at(i).text()).toContain(incidentsCount[status]);
- });
- });
+ expect(Tracking.event).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
index ea2c512bf40..273356151fc 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
@@ -23,7 +23,6 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
<gl-form-group-stub
class="col-8 col-md-9 gl-p-0"
label="Webhook URL"
- label-class="label-bold"
label-for="url"
>
<gl-form-input-group-stub
diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
index 25bb160c0e0..cd0266068aa 100644
--- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
+++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
@@ -114,7 +114,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
<!---->
<div
- class="gl-search-box-by-type gl-m-3"
+ class="gl-search-box-by-type"
>
<svg
class="gl-search-box-by-type-search-icon gl-icon s16"
@@ -225,7 +225,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
<!---->
<div
- class="gl-search-box-by-type gl-m-3"
+ class="gl-search-box-by-type"
>
<svg
class="gl-search-box-by-type-search-icon gl-icon s16"
diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
index a28ecac00fd..645aca0b157 100644
--- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
@@ -52,7 +52,6 @@ exports[`Dashboard template matches the default snapshot 1`] = `
</gl-dropdown-section-header-stub>
<gl-search-box-by-type-stub
- class="gl-m-3"
clearbuttontitle="Clear"
value=""
/>
diff --git a/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap
index e7ccecf5f61..26785855369 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap
@@ -2,17 +2,13 @@
exports[`Editor Lite component rendering matches the snapshot 1`] = `
<div
- class="file-content code"
+ data-editor-loading=""
+ id="editor-lite-snippet_777"
>
- <div
- data-editor-loading=""
- id="editor"
+ <pre
+ class="editor-loading-content"
>
- <pre
- class="editor-loading-content"
- >
- Lorem ipsum dolor sit amet, consectetur adipiscing elit.
- </pre>
- </div>
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ </pre>
</div>
`;
diff --git a/spec/frontend/vue_shared/components/editor_lite_spec.js b/spec/frontend/vue_shared/components/editor_lite_spec.js
index 514c1526e2e..48005484b91 100644
--- a/spec/frontend/vue_shared/components/editor_lite_spec.js
+++ b/spec/frontend/vue_shared/components/editor_lite_spec.js
@@ -10,6 +10,7 @@ describe('Editor Lite component', () => {
const onDidChangeModelContent = jest.fn();
const updateModelLanguage = jest.fn();
const getValue = jest.fn();
+ const setValue = jest.fn();
const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const fileName = 'lorem.txt';
const fileGlobalId = 'snippet_777';
@@ -17,6 +18,7 @@ describe('Editor Lite component', () => {
onDidChangeModelContent,
updateModelLanguage,
getValue,
+ setValue,
dispose: jest.fn(),
}));
Editor.mockImplementation(() => {
@@ -68,7 +70,7 @@ describe('Editor Lite component', () => {
createComponent({ value: undefined });
expect(spy).not.toHaveBeenCalled();
- expect(wrapper.find('#editor').exists()).toBe(true);
+ expect(wrapper.find('[id^="editor-lite-"]').exists()).toBe(true);
});
it('initialises Editor Lite instance', () => {
@@ -94,6 +96,17 @@ describe('Editor Lite component', () => {
});
});
+ it('reacts to the changes in the pased value', async () => {
+ const newValue = 'New Value';
+
+ wrapper.setProps({
+ value: newValue,
+ });
+
+ await nextTick();
+ expect(setValue).toHaveBeenCalledWith(newValue);
+ });
+
it('registers callback with editor onChangeContent', () => {
expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function));
});
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json
new file mode 100644
index 00000000000..0d85b2bc68a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json
@@ -0,0 +1,15 @@
+[
+ {
+ "iid": "1527542",
+ "title": "SyntaxError: Invalid or unexpected token",
+ "createdAt": "2020-04-17T23:18:14.996Z",
+ "assignees": { "nodes": [] }
+ },
+ {
+ "iid": "1527543",
+ "title": "SyntaxError: Invalid or unexpected token by root",
+ "createdAt": "2020-04-17T23:19:14.996Z",
+ "assignees": { "nodes": [] }
+ }
+ ]
+ \ No newline at end of file
diff --git a/spec/frontend/incidents/mocks/incidents_filter.json b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json
index 9f54e259b1d..b42ec42d8b8 100644
--- a/spec/frontend/incidents/mocks/incidents_filter.json
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json
@@ -1,4 +1,4 @@
- [
+[
{
"type": "assignee_username",
"value": { "data": "root2" }
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
new file mode 100644
index 00000000000..d943aaf3e5f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
@@ -0,0 +1,350 @@
+import { mount } from '@vue/test-utils';
+import { GlAlert, GlBadge, GlPagination, GlTabs, GlTab } from '@gitlab/ui';
+import PageWrapper from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.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 Tracking from '~/tracking';
+import mockItems from './mocks/items.json';
+import mockFilters from './mocks/items_filters.json';
+
+const EmptyStateSlot = {
+ template: '<div class="empty-state">Empty State</div>',
+};
+
+const HeaderActionsSlot = {
+ template: '<div class="header-actions"><button>Action Button</button></div>',
+};
+
+const TitleSlot = {
+ template: '<div>Page Wrapper Title</div>',
+};
+
+const TableSlot = {
+ template: '<table class="gl-table"></table>',
+};
+
+const itemsCount = {
+ opened: 24,
+ closed: 10,
+ all: 34,
+};
+
+const ITEMS_STATUS_TABS = [
+ {
+ title: 'Opened items',
+ status: 'OPENED',
+ filters: ['opened'],
+ },
+ {
+ title: 'Closed items',
+ status: 'CLOSED',
+ filters: ['closed'],
+ },
+ {
+ title: 'All items',
+ status: 'ALL',
+ filters: ['all'],
+ },
+];
+
+describe('AlertManagementEmptyState', () => {
+ let wrapper;
+
+ function mountComponent({ props = {} } = {}) {
+ wrapper = mount(PageWrapper, {
+ provide: {
+ projectPath: '/link',
+ },
+ propsData: {
+ items: [],
+ itemsCount: {},
+ pageInfo: {},
+ statusTabs: [],
+ loading: false,
+ showItems: false,
+ showErrorMsg: false,
+ trackViewsOptions: {},
+ i18n: {},
+ serverErrorMessage: '',
+ filterSearchKey: '',
+ ...props,
+ },
+ slots: {
+ 'emtpy-state': EmptyStateSlot,
+ 'header-actions': HeaderActionsSlot,
+ title: TitleSlot,
+ table: TableSlot,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ const EmptyState = () => wrapper.find('.empty-state');
+ const ItemsTable = () => wrapper.find('.gl-table');
+ const ErrorAlert = () => wrapper.find(GlAlert);
+ const Pagination = () => wrapper.find(GlPagination);
+ const Tabs = () => wrapper.find(GlTabs);
+ const ActionButton = () => wrapper.find('.header-actions > button');
+ const Filters = () => wrapper.find(FilteredSearchBar);
+ const findPagination = () => wrapper.find(GlPagination);
+ const findStatusFilterTabs = () => wrapper.findAll(GlTab);
+ const findStatusTabs = () => wrapper.find(GlTabs);
+ const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
+
+ describe('Snowplow tracking', () => {
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ mountComponent({
+ props: { trackViewsOptions: { category: 'category', action: 'action' } },
+ });
+ });
+
+ it('should track the items list page views', () => {
+ const { category, action } = wrapper.vm.trackViewsOptions;
+ expect(Tracking.event).toHaveBeenCalledWith(category, action);
+ });
+ });
+
+ describe('Page wrapper with no items', () => {
+ it('renders the empty state if there are no items present', () => {
+ expect(EmptyState().exists()).toBe(true);
+ });
+ });
+
+ describe('Page wrapper with items', () => {
+ it('renders the tabs selection with valid tabs', () => {
+ mountComponent({
+ props: {
+ statusTabs: [{ status: 'opened', title: 'Open' }, { status: 'closed', title: 'Closed' }],
+ },
+ });
+
+ expect(Tabs().exists()).toBe(true);
+ });
+
+ it('renders the header action buttons if present', () => {
+ expect(ActionButton().exists()).toBe(true);
+ });
+
+ it('renders a error alert if there are errors', () => {
+ mountComponent({
+ props: { showErrorMsg: true },
+ });
+
+ expect(ErrorAlert().exists()).toBe(true);
+ });
+
+ it('renders a table of items if items are present', () => {
+ mountComponent({
+ props: { showItems: true, items: mockItems },
+ });
+
+ expect(ItemsTable().exists()).toBe(true);
+ });
+
+ it('renders pagination if there the pagination info object has a next or previous page', () => {
+ mountComponent({
+ props: { pageInfo: { hasNextPage: true } },
+ });
+
+ expect(Pagination().exists()).toBe(true);
+ });
+
+ it('renders the filter set with the tokens according to the prop filterSearchTokens', () => {
+ mountComponent({
+ props: { filterSearchTokens: ['assignee_username'] },
+ });
+
+ expect(Filters().exists()).toBe(true);
+ });
+ });
+
+ describe('Status Filter Tabs', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: { items: mockItems, itemsCount, statusTabs: ITEMS_STATUS_TABS },
+ });
+ });
+
+ it('should display filter tabs', () => {
+ const tabs = findStatusFilterTabs().wrappers;
+
+ tabs.forEach((tab, i) => {
+ expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status);
+ });
+ });
+
+ it('should display filter tabs with items count badge for each status', () => {
+ const tabs = findStatusFilterTabs().wrappers;
+ const badges = findStatusFilterBadge();
+
+ tabs.forEach((tab, i) => {
+ const status = ITEMS_STATUS_TABS[i].status.toLowerCase();
+ expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status);
+ expect(badges.at(i).text()).toContain(itemsCount[status]);
+ });
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: {
+ items: mockItems,
+ itemsCount,
+ statusTabs: ITEMS_STATUS_TABS,
+ pageInfo: { hasNextPage: true },
+ },
+ });
+ });
+
+ it('should render pagination', () => {
+ expect(wrapper.find(GlPagination).exists()).toBe(true);
+ });
+
+ describe('prevPage', () => {
+ it('returns prevPage button', async () => {
+ findPagination().vm.$emit('input', 3);
+
+ await wrapper.vm.$nextTick();
+ expect(
+ findPagination()
+ .findAll('.page-item')
+ .at(0)
+ .text(),
+ ).toBe('Prev');
+ });
+
+ it('returns prevPage number', async () => {
+ findPagination().vm.$emit('input', 3);
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.previousPage).toBe(2);
+ });
+
+ it('returns 0 when it is the first page', async () => {
+ findPagination().vm.$emit('input', 1);
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.previousPage).toBe(0);
+ });
+ });
+
+ describe('nextPage', () => {
+ it('returns nextPage button', async () => {
+ findPagination().vm.$emit('input', 3);
+
+ await wrapper.vm.$nextTick();
+ expect(
+ findPagination()
+ .findAll('.page-item')
+ .at(1)
+ .text(),
+ ).toBe('Next');
+ });
+
+ it('returns nextPage number', async () => {
+ mountComponent({
+ props: {
+ items: mockItems,
+ itemsCount,
+ statusTabs: ITEMS_STATUS_TABS,
+ pageInfo: { hasNextPage: true },
+ },
+ });
+ findPagination().vm.$emit('input', 1);
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.nextPage).toBe(2);
+ });
+
+ it('returns `null` when currentPage is already last page', async () => {
+ findStatusTabs().vm.$emit('input', 1);
+ findPagination().vm.$emit('input', 1);
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.nextPage).toBeNull();
+ });
+ });
+ });
+
+ describe('Filtered search component', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: {
+ items: mockItems,
+ itemsCount,
+ statusTabs: ITEMS_STATUS_TABS,
+ filterSearchKey: 'items',
+ },
+ });
+ });
+
+ it('renders the search component for incidents', () => {
+ expect(Filters().props('searchInputPlaceholder')).toBe('Search or filter results…');
+ expect(Filters().props('tokens')).toEqual([
+ {
+ type: 'author_username',
+ icon: 'user',
+ title: 'Author',
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchPath: '/link',
+ fetchAuthors: expect.any(Function),
+ },
+ {
+ type: 'assignee_username',
+ icon: 'user',
+ title: 'Assignee',
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchPath: '/link',
+ fetchAuthors: expect.any(Function),
+ },
+ ]);
+ expect(Filters().props('recentSearchesStorageKey')).toBe('items');
+ });
+
+ it('returns correctly applied filter search values', async () => {
+ const searchTerm = 'foo';
+ wrapper.setData({
+ searchTerm,
+ });
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]);
+ });
+
+ it('updates props tied to getIncidents GraphQL query', () => {
+ wrapper.vm.handleFilterItems(mockFilters);
+
+ expect(wrapper.vm.authorUsername).toBe('root');
+ expect(wrapper.vm.assigneeUsername).toEqual('root2');
+ expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data);
+ });
+
+ it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => {
+ wrapper.setData({
+ authorUsername: 'foo',
+ searchTerm: 'bar',
+ });
+
+ wrapper.vm.handleFilterItems([]);
+
+ expect(wrapper.vm.authorUsername).toBe('');
+ expect(wrapper.vm.searchTerm).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
index b43bb6b10e0..c208d7b0226 100644
--- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
+++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
@@ -21,6 +21,9 @@ describe('User Popover Component', () => {
let wrapper;
beforeEach(() => {
+ window.gon.features = {
+ securityAutoFix: true,
+ };
loadFixtures(fixtureTemplate);
});
@@ -28,6 +31,7 @@ describe('User Popover Component', () => {
wrapper.destroy();
});
+ const findByTestId = testid => wrapper.find(`[data-testid="${testid}"]`);
const findUserStatus = () => wrapper.find('.js-user-status');
const findTarget = () => document.querySelector('.js-user-link');
@@ -196,4 +200,30 @@ describe('User Popover Component', () => {
expect(findUserStatus().exists()).toBe(false);
});
});
+
+ describe('security bot', () => {
+ const SECURITY_BOT_USER = {
+ ...DEFAULT_PROPS.user,
+ name: 'GitLab Security Bot',
+ username: 'GitLab-Security-Bot',
+ websiteUrl: '/security/bot/docs',
+ };
+ const findSecurityBotDocsLink = () => findByTestId('user-popover-bot-docs-link');
+
+ it("shows a link to the bot's documentation", () => {
+ createWrapper({ user: SECURITY_BOT_USER });
+ const securityBotDocsLink = findSecurityBotDocsLink();
+ expect(securityBotDocsLink.exists()).toBe(true);
+ expect(securityBotDocsLink.attributes('href')).toBe(SECURITY_BOT_USER.websiteUrl);
+ });
+
+ it('does not show the link if the feature flag is disabled', () => {
+ window.gon.features = {
+ securityAutoFix: false,
+ };
+ createWrapper({ user: SECURITY_BOT_USER });
+
+ expect(findSecurityBotDocsLink().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb
index 6bdd6fb6813..b85ebec5545 100644
--- a/spec/helpers/boards_helper_spec.rb
+++ b/spec/helpers/boards_helper_spec.rb
@@ -34,28 +34,58 @@ RSpec.describe BoardsHelper do
end
describe '#board_data' do
- let(:user) { create(:user) }
- let(:board) { create(:board, project: project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:board) { create(:board, project: project) }
- before do
- assign(:board, board)
- assign(:project, project)
+ context 'project_board' do
+ before do
+ assign(:project, project)
+ assign(:board, board)
- allow(helper).to receive(:current_user) { user }
- allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true)
- allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true)
- end
+ allow(helper).to receive(:current_user) { user }
+ allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true)
+ allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true)
+ end
- it 'returns a board_lists_path as lists_endpoint' do
- expect(helper.board_data[:lists_endpoint]).to eq(board_lists_path(board))
- end
+ it 'returns a board_lists_path as lists_endpoint' do
+ expect(helper.board_data[:lists_endpoint]).to eq(board_lists_path(board))
+ end
- it 'returns board type as parent' do
- expect(helper.board_data[:parent]).to eq('project')
+ it 'returns board type as parent' do
+ expect(helper.board_data[:parent]).to eq('project')
+ end
+
+ it 'returns can_update for user permissions on the board' do
+ expect(helper.board_data[:can_update]).to eq('true')
+ end
+
+ it 'returns required label endpoints' do
+ expect(helper.board_data[:labels_fetch_path]).to eq("/#{project.full_path}/-/labels.json?include_ancestor_groups=true")
+ expect(helper.board_data[:labels_manage_path]).to eq("/#{project.full_path}/-/labels")
+ end
end
- it 'returns can_update for user permissions on the board' do
- expect(helper.board_data[:can_update]).to eq('true')
+ context 'group board' do
+ let_it_be(:group) { create(:group, path: 'base') }
+ let_it_be(:board) { create(:board, group: group) }
+
+ before do
+ assign(:group, group)
+ assign(:board, board)
+
+ allow(helper).to receive(:current_user) { user }
+ allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true)
+ allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true)
+ end
+
+ it 'returns correct path for base group' do
+ expect(helper.build_issue_link_base).to eq('/base/:project_path/issues')
+ end
+
+ it 'returns required label endpoints' do
+ expect(helper.board_data[:labels_fetch_path]).to eq("/groups/base/-/labels.json?include_ancestor_groups=true&only_group_labels=true")
+ expect(helper.board_data[:labels_manage_path]).to eq("/groups/base/-/labels")
+ end
end
end
diff --git a/spec/helpers/labels_helper_spec.rb b/spec/helpers/labels_helper_spec.rb
index 77e1d10354c..555decd284a 100644
--- a/spec/helpers/labels_helper_spec.rb
+++ b/spec/helpers/labels_helper_spec.rb
@@ -291,4 +291,34 @@ RSpec.describe LabelsHelper do
expect(tooltip).to eq('This is an image')
end
end
+
+ describe '#show_labels_full_path?' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:project) { create(:project, group: group) }
+
+ context 'within a project' do
+ it 'returns truthy' do
+ expect(show_labels_full_path?(project, nil)).to be_truthy
+ end
+ end
+
+ context 'within a subgroup' do
+ it 'returns truthy' do
+ expect(show_labels_full_path?(nil, subgroup)).to be_truthy
+ end
+ end
+
+ context 'within a group' do
+ it 'returns falsey' do
+ expect(show_labels_full_path?(nil, group)).to be_falsey
+ end
+ end
+
+ context 'within the admin area' do
+ it 'returns falsey' do
+ expect(show_labels_full_path?(nil, nil)).to be_falsey
+ end
+ end
+ end
end
diff --git a/spec/helpers/projects/alert_management_helper_spec.rb b/spec/helpers/projects/alert_management_helper_spec.rb
index 183f0438c35..83b89abde58 100644
--- a/spec/helpers/projects/alert_management_helper_spec.rb
+++ b/spec/helpers/projects/alert_management_helper_spec.rb
@@ -32,7 +32,9 @@ RSpec.describe Projects::AlertManagementHelper do
'populating-alerts-help-url' => 'http://test.host/help/operations/incident_management/index.md#enable-alert-management',
'empty-alert-svg-path' => match_asset_path('/assets/illustrations/alert-management-empty-state.svg'),
'user-can-enable-alert-management' => 'true',
- 'alert-management-enabled' => 'false'
+ 'alert-management-enabled' => 'false',
+ 'text-query': nil,
+ 'assignee-username-query': nil
)
end
end
diff --git a/spec/helpers/projects/incidents_helper_spec.rb b/spec/helpers/projects/incidents_helper_spec.rb
index 5caf98f5693..7a8a6d5222f 100644
--- a/spec/helpers/projects/incidents_helper_spec.rb
+++ b/spec/helpers/projects/incidents_helper_spec.rb
@@ -29,8 +29,8 @@ RSpec.describe Projects::IncidentsHelper do
'issue-path' => issue_path,
'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'),
'text-query': 'search text',
- 'author-usernames-query': 'root',
- 'assignee-usernames-query': 'max.power'
+ 'author-username-query': 'root',
+ 'assignee-username-query': 'max.power'
)
end
end
diff --git a/spec/services/ci/create_downstream_pipeline_service_spec.rb b/spec/services/ci/create_downstream_pipeline_service_spec.rb
index a6ea30e4703..0f930c07373 100644
--- a/spec/services/ci/create_downstream_pipeline_service_spec.rb
+++ b/spec/services/ci/create_downstream_pipeline_service_spec.rb
@@ -595,5 +595,40 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
)
end
end
+
+ context 'when downstream pipeline has workflow rule' do
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $my_var
+
+ regular-job:
+ script: 'echo Hello, World!'
+ EOY
+ end
+
+ context 'when passing the required variable' do
+ before do
+ bridge.yaml_variables = [{ key: 'my_var', value: 'var', public: true }]
+ end
+
+ it 'creates the pipeline' do
+ expect { service.execute(bridge) }.to change(downstream_project.ci_pipelines, :count).by(1)
+
+ expect(bridge.reload).to be_success
+ end
+ end
+
+ context 'when not passing the required variable' do
+ it 'does not create the pipeline' do
+ expect { service.execute(bridge) }.not_to change(downstream_project.ci_pipelines, :count)
+ end
+ end
+ end
end
end
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index c28c3449485..9162e1a887a 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -41,7 +41,9 @@ RSpec.describe Ci::CreatePipelineService do
save_on_errors: save_on_errors,
trigger_request: trigger_request,
merge_request: merge_request,
- external_pull_request: external_pull_request)
+ external_pull_request: external_pull_request) do |pipeline|
+ yield(pipeline) if block_given?
+ end
end
# rubocop:enable Metrics/ParameterLists
@@ -2274,6 +2276,108 @@ RSpec.describe Ci::CreatePipelineService do
end
end
end
+
+ context 'with workflow rules with persisted variables' do
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $CI_COMMIT_REF_NAME == "master"
+
+ regular-job:
+ script: 'echo Hello, World!'
+ EOY
+ end
+
+ context 'with matches' do
+ it 'creates a pipeline' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('regular-job')
+ end
+ end
+
+ context 'with no matches' do
+ let(:ref_name) { 'refs/heads/feature' }
+
+ it 'does not create a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+ end
+ end
+
+ context 'with workflow rules with pipeline variables' do
+ let(:pipeline) do
+ execute_service(variables_attributes: variables_attributes)
+ end
+
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $SOME_VARIABLE
+
+ regular-job:
+ script: 'echo Hello, World!'
+ EOY
+ end
+
+ context 'with matches' do
+ let(:variables_attributes) do
+ [{ key: 'SOME_VARIABLE', secret_value: 'SOME_VAR' }]
+ end
+
+ it 'creates a pipeline' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('regular-job')
+ end
+ end
+
+ context 'with no matches' do
+ let(:variables_attributes) { {} }
+
+ it 'does not create a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+ end
+ end
+
+ context 'with workflow rules with trigger variables' do
+ let(:pipeline) do
+ execute_service do |pipeline|
+ pipeline.variables.build(variables)
+ end
+ end
+
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $SOME_VARIABLE
+
+ regular-job:
+ script: 'echo Hello, World!'
+ EOY
+ end
+
+ context 'with matches' do
+ let(:variables) do
+ [{ key: 'SOME_VARIABLE', secret_value: 'SOME_VAR' }]
+ end
+
+ it 'creates a pipeline' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('regular-job')
+ end
+ end
+
+ context 'with no matches' do
+ let(:variables) { {} }
+
+ it 'does not create a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+ end
+ end
end
end
diff --git a/spec/services/ci/pipeline_trigger_service_spec.rb b/spec/services/ci/pipeline_trigger_service_spec.rb
index ac077e3c30e..7cd0b8dd2ab 100644
--- a/spec/services/ci/pipeline_trigger_service_spec.rb
+++ b/spec/services/ci/pipeline_trigger_service_spec.rb
@@ -161,6 +161,29 @@ RSpec.describe Ci::PipelineTriggerService do
expect(result[:pipeline].variables.map { |v| { v.key => v.value } }.first).to eq(variables)
expect(job.sourced_pipelines.last.pipeline_id).to eq(result[:pipeline].id)
end
+
+ context 'when the config has workflow rule with the variable' do
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $AAA
+
+ regular-job:
+ script: 'echo Hello, World!'
+ EOY
+ end
+
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ it 'runs the pipeline' do
+ expect { result }.to change { Ci::Pipeline.count }.by(1)
+
+ expect(result[:status]).to eq(:success)
+ end
+ end
end
end
diff --git a/spec/support/helpers/features/snippet_helpers.rb b/spec/support/helpers/features/snippet_helpers.rb
index 8090dd2be73..c26849a9680 100644
--- a/spec/support/helpers/features/snippet_helpers.rb
+++ b/spec/support/helpers/features/snippet_helpers.rb
@@ -31,7 +31,7 @@ module Spec
end
def snippet_get_first_blob_value
- page.find('.file-content', match: :first)
+ page.find('.gl-editor-lite', match: :first)
end
def snippet_description_value
@@ -53,7 +53,7 @@ module Spec
end
def snippet_fill_in_content(value)
- page.within('.file-editor') do
+ page.within('.gl-editor-lite') do
el = find('.inputarea')
el.send_keys value
end
diff --git a/spec/views/shared/_label_row.html.haml_spec.rb b/spec/views/shared/_label_row.html.haml_spec.rb
index 1d21574a0c4..e9a0bfdcd4e 100644
--- a/spec/views/shared/_label_row.html.haml_spec.rb
+++ b/spec/views/shared/_label_row.html.haml_spec.rb
@@ -19,23 +19,58 @@ RSpec.describe 'shared/_label_row.html.haml' do
render
end
+ it 'has label title' do
+ expect(rendered).to have_text(label.title)
+ end
+
it 'has a non-linked label title' do
- expect(rendered).not_to have_css('a', text: label.title)
+ expect(rendered).not_to have_link(label.title)
end
- it "has Issues link" do
- expect(rendered).to have_css('a', text: 'Issues')
+ it 'has Issues link' do
+ expect(rendered).to have_link('Issues')
end
- it "has Merge request link" do
- expect(rendered).to have_css('a', text: 'Merge requests')
+ it 'has Merge request link' do
+ expect(rendered).to have_link('Merge requests')
end
- it "shows the path from where the label was created" do
+ it 'shows the path from where the label was created' do
expect(rendered).to have_css('.label-badge', text: project.full_name)
end
end
+ context 'with a subgroup context' do
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let(:label) { build_stubbed(:group_label, group: subgroup).present(issuable_subject: subgroup) }
+
+ before do
+ assign(:group, label.group)
+
+ render
+ end
+
+ it 'has label title' do
+ expect(rendered).to have_text(label.title)
+ end
+
+ it 'has a non-linked label title' do
+ expect(rendered).not_to have_link(label.title)
+ end
+
+ it 'has Issues link' do
+ expect(rendered).to have_link('Issues')
+ end
+
+ it 'has Merge request link' do
+ expect(rendered).to have_link('Merge requests')
+ end
+
+ it 'shows the path from where the label was created' do
+ expect(rendered).to have_css('.label-badge', text: subgroup.full_name)
+ end
+ end
+
context 'with a group context' do
before do
assign(:group, label.group)
@@ -43,19 +78,23 @@ RSpec.describe 'shared/_label_row.html.haml' do
render
end
+ it 'has label title' do
+ expect(rendered).to have_text(label.title)
+ end
+
it 'has a non-linked label title' do
- expect(rendered).not_to have_css('a', text: label.title)
+ expect(rendered).not_to have_link(label.title)
end
- it "has Issues link" do
- expect(rendered).to have_css('a', text: 'Issues')
+ it 'has Issues link' do
+ expect(rendered).to have_link('Issues')
end
- it "has Merge request link" do
- expect(rendered).to have_css('a', text: 'Merge requests')
+ it 'has Merge request link' do
+ expect(rendered).to have_link('Merge requests')
end
- it "does not show a path from where the label was created" do
+ it 'does not show a path from where the label was created' do
expect(rendered).not_to have_css('.label-badge')
end
end
@@ -65,19 +104,23 @@ RSpec.describe 'shared/_label_row.html.haml' do
render
end
+ it 'has label title' do
+ expect(rendered).to have_text(label.title)
+ end
+
it 'has a non-linked label title' do
- expect(rendered).not_to have_css('a', text: label.title)
+ expect(rendered).not_to have_link(label.title)
end
- it "does not show Issues link" do
- expect(rendered).not_to have_css('a', text: 'Issues')
+ it 'does not show Issues link' do
+ expect(rendered).not_to have_link('Issues')
end
- it "does not show Merge request link" do
- expect(rendered).not_to have_css('a', text: 'Merge requests')
+ it 'does not show Merge request link' do
+ expect(rendered).not_to have_link('Merge requests')
end
- it "does not show a path from where the label was created" do
+ it 'does not show a path from where the label was created' do
expect(rendered).not_to have_css('.label-badge')
end
end
diff --git a/yarn.lock b/yarn.lock
index 192a2a35e7c..ccdabf8e53b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -866,10 +866,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.171.0.tgz#abc3092bf804f0898301626130e0f3231834924a"
integrity sha512-TPfdqIxQDda+0CQHhb9XdF50lmqDmADu6yT8R4oZi6BoUtWLdiHbyFt+RnVU6t7EmjIKicNAii7Ga+f2ljCfUA==
-"@gitlab/ui@21.28.0":
- version "21.28.0"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.28.0.tgz#28455d9f53ed34c0b17ea8e1073b670c59617032"
- integrity sha512-skhWKaC3hzWpLA6GoDLG5qJqdgRhYNfAtE2W7pONyfi21eUgZuMbzCVSX3dYLm6v2LEBsJRZXbguWmCOT2ZilQ==
+"@gitlab/ui@21.30.1":
+ version "21.30.1"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.30.1.tgz#561bd10c5264e48d16be4c5706827f40ec31286f"
+ integrity sha512-JdLA8x7fOk1qgsqBeNhlmg1VTJIrl+ECWea8whoyqWnI95GPuiM5ZSnehZHA6FoNwB9n/u1GnVSLCHcqvHZW0w==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"