Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue1
-rw-r--r--app/assets/javascripts/access_tokens/components/token.vue1
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue27
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue46
-rw-r--r--app/assets/javascripts/admin/abuse_reports/constants.js40
-rw-r--r--app/assets/javascripts/admin/abuse_reports/index.js1
-rw-r--r--app/assets/javascripts/admin/abuse_reports/utils.js18
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue7
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/constants.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js7
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token.vue1
-rw-r--r--app/assets/javascripts/custom_emoji/components/app.vue15
-rw-r--r--app/assets/javascripts/custom_emoji/custom_emoji_bundle.js39
-rw-r--r--app/assets/javascripts/custom_emoji/pages/index.vue7
-rw-r--r--app/assets/javascripts/custom_emoji/pages/new.vue11
-rw-r--r--app/assets/javascripts/custom_emoji/routes.js13
-rw-r--r--app/assets/javascripts/diffs/components/app.vue42
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diffs_file_tree.vue78
-rw-r--r--app/assets/javascripts/diffs/store/actions.js4
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue54
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form_actions.vue124
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_forms/section.vue53
-rw-r--r--app/assets/javascripts/lib/mousetrap.js2
-rw-r--r--app/assets/javascripts/oauth_application/components/oauth_secret.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/flyout_menu.vue65
-rw-r--r--app/assets/javascripts/super_sidebar/components/menu_section.vue34
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/pinned_section.vue14
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue64
-rw-r--r--app/assets/stylesheets/framework/new_card.scss43
-rw-r--r--app/finders/abuse_reports_finder.rb60
-rw-r--r--app/models/abuse_report.rb18
-rw-r--r--app/models/ci/pipeline.rb1
-rw-r--r--app/models/integrations/chat_message/issue_message.rb4
-rw-r--r--app/models/note.rb4
-rw-r--r--app/serializers/admin/abuse_report_entity.rb7
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml3
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/profiles/keys/_key_details.html.haml109
-rw-r--r--app/views/projects/settings/integrations/_form.html.haml4
-rw-r--r--app/views/shared/ssh_keys/_key_delete.html.haml5
-rw-r--r--config/feature_flags/development/mr_activity_filters.yml4
-rw-r--r--config/feature_flags/development/restrict_special_characters_in_namespace_path.yml2
-rw-r--r--config/feature_flags/development/super_sidebar_flyout_menus.yml (renamed from config/feature_flags/development/track_work_items_activity.yml)12
-rw-r--r--config/gitlab_loose_foreign_keys.yml21
-rw-r--r--config/metrics/counts_28d/20220222215951_xmau_plan.yml2
-rw-r--r--config/metrics/counts_28d/20220222215952_xmau_project_management.yml2
-rw-r--r--config/metrics/counts_28d/20220222215955_users_work_items.yml2
-rw-r--r--config/metrics/counts_7d/20220222215851_xmau_plan.yml2
-rw-r--r--config/metrics/counts_7d/20220222215852_xmau_project_management.yml2
-rw-r--r--config/metrics/counts_7d/20220222215855_users_work_items.yml2
-rw-r--r--data/deprecations/16-2-graphql-board-list-totalweight.yml11
-rw-r--r--db/migrate/20230717055659_initialize_conversion_of_ci_pipelines_auto_canceled_by_id.rb16
-rw-r--r--db/post_migrate/20230717055730_backfill_ci_pipelines_auto_canceled_by_id_conversion.rb17
-rw-r--r--db/post_migrate/20230721181046_drop_index_issues_on_project_id_and_created_at_issue_type_incident.rb15
-rw-r--r--db/post_migrate/20230721194757_drop_index_issues_on_incident_issue_type.rb15
-rw-r--r--db/post_migrate/20230721200323_drop_index_on_issues_closed_incidents_by_project_id_and_closed_at.rb15
-rw-r--r--db/post_migrate/20230721200810_drop_index_on_issues_health_status_asc_order.rb18
-rw-r--r--db/post_migrate/20230721200849_drop_index_on_issues_health_status_desc_order.rb18
-rw-r--r--db/schema_migrations/202307170556591
-rw-r--r--db/schema_migrations/202307170557301
-rw-r--r--db/schema_migrations/202307211810461
-rw-r--r--db/schema_migrations/202307211947571
-rw-r--r--db/schema_migrations/202307212003231
-rw-r--r--db/schema_migrations/202307212008101
-rw-r--r--db/schema_migrations/202307212008491
-rw-r--r--db/structure.sql22
-rw-r--r--doc/administration/snippets/index.md11
-rw-r--r--doc/api/merge_request_approvals.md16
-rw-r--r--doc/raketasks/x509_signatures.md10
-rw-r--r--doc/update/deprecations.md16
-rw-r--r--doc/user/group/value_stream_analytics/index.md3
-rw-r--r--doc/user/project/merge_requests/changes.md4
-rw-r--r--lib/gitlab/gon_helper.rb1
-rw-r--r--lib/gitlab/regex.rb4
-rw-r--r--lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric.rb13
-rw-r--r--lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb2
-rw-r--r--lib/slack_markdown_sanitizer.rb4
-rw-r--r--locale/gitlab.pot49
-rw-r--r--package.json1
-rw-r--r--qa/allure/categories.json57
-rw-r--r--qa/qa/runtime/allure_report.rb3
-rw-r--r--qa/qa/specs/features/browser_ui/5_package/container_registry/saas/container_registry_spec.rb81
-rw-r--r--qa/qa/specs/features/browser_ui/5_package/container_registry/self_managed/container_registry_spec.rb48
-rw-r--r--spec/features/admin/admin_abuse_reports_spec.rb214
-rw-r--r--spec/features/profiles/keys_spec.rb2
-rw-r--r--spec/finders/abuse_reports_finder_spec.rb225
-rw-r--r--spec/frontend/access_tokens/components/new_access_token_app_spec.js1
-rw-r--r--spec/frontend/access_tokens/components/token_spec.js1
-rw-r--r--spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js15
-rw-r--r--spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js91
-rw-r--r--spec/frontend/admin/abuse_reports/mock_data.js2
-rw-r--r--spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js8
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js5
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_token_spec.js11
-rw-r--r--spec/frontend/diffs/components/app_spec.js43
-rw-r--r--spec/frontend/diffs/components/compare_versions_spec.js2
-rw-r--r--spec/frontend/diffs/components/diffs_file_tree_spec.js116
-rw-r--r--spec/frontend/oauth_application/components/oauth_secret_spec.js4
-rw-r--r--spec/frontend/super_sidebar/components/flyout_menu_spec.js25
-rw-r--r--spec/frontend/super_sidebar/components/menu_section_spec.js36
-rw-r--r--spec/frontend/super_sidebar/components/pinned_section_spec.js17
-rw-r--r--spec/frontend/super_sidebar/components/sidebar_menu_spec.js64
-rw-r--r--spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js254
-rw-r--r--spec/lib/gitlab/regex_spec.rb12
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric_spec.rb72
-rw-r--r--spec/lib/slack_markdown_sanitizer_spec.rb17
-rw-r--r--spec/models/abuse_report_spec.rb28
-rw-r--r--spec/models/integrations/chat_message/issue_message_spec.rb10
-rw-r--r--spec/models/note_spec.rb18
-rw-r--r--spec/serializers/admin/abuse_report_entity_spec.rb1
-rw-r--r--spec/support/shared_examples/features/runners_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/usage_data_counters/work_item_activity_unique_counter_shared_examples.rb34
-rw-r--r--yarn.lock2
117 files changed, 2117 insertions, 812 deletions
diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
index 02159d4d524..dfac423b65e 100644
--- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -112,6 +112,7 @@ export default {
:label-for="$options.tokenInputId"
:value="newToken"
:form-input-group-props="formInputGroupProps"
+ readonly
>
<template #description>
{{ $options.i18n.description }}
diff --git a/app/assets/javascripts/access_tokens/components/token.vue b/app/assets/javascripts/access_tokens/components/token.vue
index 23803e82476..09263d8e0ea 100644
--- a/app/assets/javascripts/access_tokens/components/token.vue
+++ b/app/assets/javascripts/access_tokens/components/token.vue
@@ -39,6 +39,7 @@ export default {
:form-input-group-props="formInputGroupProps"
:value="token"
:copy-button-title="copyButtonTitle"
+ readonly
>
<template #description>
<slot name="input-description"></slot>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
index b229dd9e993..291833959f2 100644
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
@@ -14,6 +14,13 @@ export default {
ListItem,
AbuseCategory,
},
+ i18n: {
+ updatedAt: __('Updated %{timeAgo}'),
+ createdAt: __('Created %{timeAgo}'),
+ deletedUser: s__('AbuseReports|Deleted user'),
+ row: s__('AbuseReports|%{reportedUser} reported for %{category} by %{reporter}'),
+ rowWithCount: s__('AbuseReports|%{reportedUser} reported for %{category} by %{count} users'),
+ },
props: {
report: {
type: Object,
@@ -25,18 +32,24 @@ export default {
const { sort } = queryToObject(window.location.search);
const { createdAt, updatedAt } = this.report;
const { template, timeAgo } = Object.values(SORT_UPDATED_AT.sortDirection).includes(sort)
- ? { template: __('Updated %{timeAgo}'), timeAgo: updatedAt }
- : { template: __('Created %{timeAgo}'), timeAgo: createdAt };
+ ? { template: this.$options.i18n.updatedAt, timeAgo: updatedAt }
+ : { template: this.$options.i18n.createdAt, timeAgo: createdAt };
return sprintf(template, { timeAgo: getTimeago().format(timeAgo) });
},
title() {
- const { reportedUser, category, reporter } = this.report;
- const template = s__('AbuseReports|%{reportedUser} reported for %{category} by %{reporter}');
- return sprintf(template, {
- reportedUser: reportedUser?.name || s__('AbuseReports|Deleted user'),
- reporter: reporter?.name || s__('AbuseReports|Deleted user'),
+ const { reportedUser, category, reporter, count } = this.report;
+
+ const reportedUserName = reportedUser?.name || this.$options.i18n.deletedUser;
+ const reporterName = reporter?.name || this.$options.i18n.deletedUser;
+
+ const i18nRowCount = count > 1 ? this.$options.i18n.rowWithCount : this.$options.i18n.row;
+
+ return sprintf(i18nRowCount, {
+ reportedUser: reportedUserName,
+ reporter: reporterName,
category,
+ count,
});
},
},
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
index b1eb5371a35..bab0fe6dd7d 100644
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
@@ -4,43 +4,52 @@ import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_ba
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import {
FILTERED_SEARCH_TOKENS,
- DEFAULT_SORT,
- SORT_OPTIONS,
- isValidSortKey,
+ DEFAULT_SORT_STATUS_OPEN,
+ DEFAULT_SORT_STATUS_CLOSED,
} from '~/admin/abuse_reports/constants';
-import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils';
+
+import {
+ buildFilteredSearchCategoryToken,
+ isValidStatus,
+ isOpenStatus,
+ isValidSortKey,
+ sortOptions,
+} from '~/admin/abuse_reports/utils';
export default {
name: 'AbuseReportsFilteredSearchBar',
components: { FilteredSearchBar },
- sortOptions: SORT_OPTIONS,
inject: ['categories'],
data() {
return {
initialFilterValue: [],
- initialSortBy: DEFAULT_SORT,
+ initialSortBy: DEFAULT_SORT_STATUS_OPEN,
};
},
computed: {
tokens() {
return [...FILTERED_SEARCH_TOKENS, buildFilteredSearchCategoryToken(this.categories)];
},
+ query() {
+ return queryToObject(window.location.search);
+ },
+ currentSortOptions() {
+ return sortOptions(this.query.status);
+ },
},
created() {
- const query = queryToObject(window.location.search);
+ const { query } = this;
// Backend shows open reports by default if status param is not specified.
// To match that behavior, update the current URL to include status=open
- // query when no status query is specified on load.
+ // query when no status is specified on load.
if (!isValidStatus(query.status)) {
query.status = 'open';
updateHistory({ url: setUrlParams(query), replace: true });
}
- const sort = this.currentSortKey();
- if (sort) {
- this.initialSortBy = query.sort;
- }
+ const sortKey = this.currentSortKey();
+ this.initialSortBy = sortKey;
const tokens = this.tokens
.filter((token) => query[token.type])
@@ -56,9 +65,13 @@ export default {
},
methods: {
currentSortKey() {
- const { sort } = queryToObject(window.location.search);
+ const { status, sort } = this.query;
- return isValidSortKey(sort) ? sort : undefined;
+ if (!isValidSortKey(status, sort) || !sort) {
+ return isOpenStatus(status) ? DEFAULT_SORT_STATUS_OPEN : DEFAULT_SORT_STATUS_CLOSED;
+ }
+
+ return sort;
},
handleFilter(tokens) {
let params = tokens.reduce((accumulator, token) => {
@@ -76,6 +89,7 @@ export default {
}, {});
const sort = this.currentSortKey();
+
if (sort) {
params = { ...params, sort };
}
@@ -83,7 +97,7 @@ export default {
redirectTo(setUrlParams(params, window.location.href, true)); // eslint-disable-line import/no-deprecated
},
handleSort(sort) {
- const { page, ...query } = queryToObject(window.location.search);
+ const { page, ...query } = this.query;
redirectTo(setUrlParams({ ...query, sort }, window.location.href, true)); // eslint-disable-line import/no-deprecated
},
@@ -101,7 +115,7 @@ export default {
:search-input-placeholder="__('Filter reports')"
:initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy"
- :sort-options="$options.sortOptions"
+ :sort-options="currentSortOptions"
data-testid="abuse-reports-filtered-search-bar"
@onFilter="handleFilter"
@onSort="handleSort"
diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js
index acb79293dfb..8b14745543e 100644
--- a/app/assets/javascripts/admin/abuse_reports/constants.js
+++ b/app/assets/javascripts/admin/abuse_reports/constants.js
@@ -7,10 +7,9 @@ import {
} from '~/vue_shared/components/filtered_search_bar/constants';
import { s__, __ } from '~/locale';
-const STATUS_OPTIONS = [
- { value: 'closed', title: __('Closed') },
- { value: 'open', title: __('Open') },
-];
+export const STATUS_OPEN = { value: 'open', title: __('Open') };
+
+const STATUS_OPTIONS = [{ value: 'closed', title: __('Closed') }, STATUS_OPEN];
export const FILTERED_SEARCH_TOKEN_USER = {
type: 'user',
@@ -39,30 +38,39 @@ export const FILTERED_SEARCH_TOKEN_STATUS = {
operators: OPERATORS_IS,
};
-export const DEFAULT_SORT = 'created_at_desc';
-export const SORT_UPDATED_AT = Object.freeze({
+export const DEFAULT_SORT_STATUS_OPEN = 'number_of_reports_desc';
+export const DEFAULT_SORT_STATUS_CLOSED = 'created_at_desc';
+
+export const SORT_UPDATED_AT = {
id: 20,
title: __('Updated date'),
sortDirection: {
descending: 'updated_at_desc',
ascending: 'updated_at_asc',
},
-});
-const SORT_CREATED_AT = Object.freeze({
+};
+
+const SORT_CREATED_AT = {
id: 10,
title: __('Created date'),
sortDirection: {
- descending: DEFAULT_SORT,
+ descending: DEFAULT_SORT_STATUS_CLOSED,
ascending: 'created_at_asc',
},
-});
+};
+
+const SORT_NUMBER_OF_REPORTS = {
+ id: 30,
+ title: __('Number of Reports'),
+ sortDirection: {
+ descending: DEFAULT_SORT_STATUS_OPEN,
+ },
+};
-export const SORT_OPTIONS = [SORT_CREATED_AT, SORT_UPDATED_AT];
+export const SORT_OPTIONS_STATUS_CLOSED = [SORT_CREATED_AT, SORT_UPDATED_AT];
-export const isValidSortKey = (key) =>
- SORT_OPTIONS.some(
- (sort) => sort.sortDirection.ascending === key || sort.sortDirection.descending === key,
- );
+// when filtered for status=open reports, add an additional sorting option -> number of reports
+export const SORT_OPTIONS_STATUS_OPEN = [SORT_NUMBER_OF_REPORTS, ...SORT_OPTIONS_STATUS_CLOSED];
export const FILTERED_SEARCH_TOKEN_CATEGORY = {
type: 'category',
@@ -74,9 +82,9 @@ export const FILTERED_SEARCH_TOKEN_CATEGORY = {
};
export const FILTERED_SEARCH_TOKENS = [
+ FILTERED_SEARCH_TOKEN_STATUS,
FILTERED_SEARCH_TOKEN_USER,
FILTERED_SEARCH_TOKEN_REPORTER,
- FILTERED_SEARCH_TOKEN_STATUS,
];
export const ABUSE_CATEGORIES = {
diff --git a/app/assets/javascripts/admin/abuse_reports/index.js b/app/assets/javascripts/admin/abuse_reports/index.js
index dbc466af2d2..e4174e6c851 100644
--- a/app/assets/javascripts/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/admin/abuse_reports/index.js
@@ -19,6 +19,7 @@ export const initAbuseReportsApp = () => {
return new Vue({
el,
+ name: 'AbuseReportsAppRoot',
provide: { categories },
render: (createElement) =>
createElement(AbuseReportsApp, {
diff --git a/app/assets/javascripts/admin/abuse_reports/utils.js b/app/assets/javascripts/admin/abuse_reports/utils.js
index d30e8fb0ae5..a3d05e4dcb3 100644
--- a/app/assets/javascripts/admin/abuse_reports/utils.js
+++ b/app/assets/javascripts/admin/abuse_reports/utils.js
@@ -1,4 +1,10 @@
-import { FILTERED_SEARCH_TOKEN_CATEGORY, FILTERED_SEARCH_TOKEN_STATUS } from './constants';
+import {
+ FILTERED_SEARCH_TOKEN_CATEGORY,
+ FILTERED_SEARCH_TOKEN_STATUS,
+ STATUS_OPEN,
+ SORT_OPTIONS_STATUS_OPEN,
+ SORT_OPTIONS_STATUS_CLOSED,
+} from './constants';
export const buildFilteredSearchCategoryToken = (categories) => {
const options = categories.map((c) => ({ value: c, title: c }));
@@ -7,3 +13,13 @@ export const buildFilteredSearchCategoryToken = (categories) => {
export const isValidStatus = (status) =>
FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value).includes(status);
+
+export const isOpenStatus = (status) => status === STATUS_OPEN.value;
+
+export const sortOptions = (status) =>
+ isOpenStatus(status) ? SORT_OPTIONS_STATUS_OPEN : SORT_OPTIONS_STATUS_CLOSED;
+
+export const isValidSortKey = (status, key) =>
+ sortOptions(status).some(
+ (sort) => sort.sortDirection.ascending === key || sort.sortDirection.descending === key,
+ );
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
index f23a6fbcaa0..d3b914ea8aa 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
@@ -1,6 +1,6 @@
<script>
import { GlSprintf, GlButton, GlAlert, GlCard } from '@gitlab/ui';
-import { Mousetrap } from '~/lib/mousetrap';
+import { Mousetrap, MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap';
import { __ } from '~/locale';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -10,7 +10,6 @@ import {
PRINT_BUTTON_ACTION,
TRACKING_LABEL_PREFIX,
RECOVERY_CODE_DOWNLOAD_FILENAME,
- COPY_KEYBOARD_SHORTCUT,
} from '../constants';
export const i18n = {
@@ -62,14 +61,14 @@ export default {
created() {
this.$options.mousetrap = new Mousetrap();
- this.$options.mousetrap.bind(COPY_KEYBOARD_SHORTCUT, this.handleKeyboardCopy);
+ this.$options.mousetrap.bind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT, this.handleKeyboardCopy);
},
beforeDestroy() {
if (!this.$options.mousetrap) {
return;
}
- this.$options.mousetrap.unbind(COPY_KEYBOARD_SHORTCUT);
+ this.$options.mousetrap.unbind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT);
},
methods: {
handleButtonClick(action) {
diff --git a/app/assets/javascripts/authentication/two_factor_auth/constants.js b/app/assets/javascripts/authentication/two_factor_auth/constants.js
index 35fc49c88b2..8ca188c293f 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/constants.js
+++ b/app/assets/javascripts/authentication/two_factor_auth/constants.js
@@ -7,5 +7,3 @@ export const TRACKING_LABEL_PREFIX = '2fa_recovery_codes_';
export const RECOVERY_CODE_DOWNLOAD_FILENAME = 'gitlab-recovery-codes.txt';
export const SUCCESS_QUERY_PARAM = 'two_factor_auth_enabled_successfully';
-
-export const COPY_KEYBOARD_SHORTCUT = 'mod+c';
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index bd13bcb35fc..a6faa04b440 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -430,6 +430,13 @@ export const MR_GO_TO_FILE = {
customizable: false,
};
+export const MR_TOGGLE_FILE_BROWSER = {
+ id: 'mergeRequests.toggleFileBrowser',
+ description: __('Toggle file browser'),
+ defaultKeys: ['f'],
+ customizable: false,
+};
+
export const MR_NEXT_UNRESOLVED_DISCUSSION = {
id: 'mergeRequests.nextUnresolvedDiscussion',
description: __('Next unresolved discussion'),
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
index 339c92a427f..50d2fcfa961 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
@@ -45,6 +45,7 @@ export default {
:label-for="inputId"
:copy-button-title="$options.I18N_COPY_BUTTON_TITLE"
:form-input-group-props="formInputGroupProps"
+ readonly
@copy="onCopy"
>
<template v-for="slot in Object.keys($scopedSlots)" #[slot]>
diff --git a/app/assets/javascripts/custom_emoji/components/app.vue b/app/assets/javascripts/custom_emoji/components/app.vue
new file mode 100644
index 00000000000..405a296397f
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/components/app.vue
@@ -0,0 +1,15 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div class="row gl-mt-5">
+ <div class="col-12">
+ <h4 class="gl-mt-0">
+ {{ __('Custom emoji') }}
+ </h4>
+ <p>{{ __('Custom emoji will be available to use in every project in group.') }}</p>
+ <router-view />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/custom_emoji/custom_emoji_bundle.js b/app/assets/javascripts/custom_emoji/custom_emoji_bundle.js
new file mode 100644
index 00000000000..1d8e64e605b
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/custom_emoji_bundle.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import routes from './routes';
+import App from './components/app.vue';
+
+export const initCustomEmojis = () => {
+ Vue.use(VueApollo);
+ Vue.use(VueRouter);
+
+ const el = document.getElementById('js-custom-emojis-root');
+
+ if (!el) return;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+ const router = new VueRouter({
+ base: el.dataset.basePath,
+ mode: 'history',
+ routes,
+ });
+ const { groupPath } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'CustomEmojiApp',
+ router,
+ apolloProvider,
+ provide: {
+ groupPath,
+ },
+ render(createElement) {
+ return createElement(App);
+ },
+ });
+};
diff --git a/app/assets/javascripts/custom_emoji/pages/index.vue b/app/assets/javascripts/custom_emoji/pages/index.vue
new file mode 100644
index 00000000000..6d32ba41eae
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/pages/index.vue
@@ -0,0 +1,7 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/custom_emoji/pages/new.vue b/app/assets/javascripts/custom_emoji/pages/new.vue
new file mode 100644
index 00000000000..659c1a0bfd3
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/pages/new.vue
@@ -0,0 +1,11 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div>
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Add new emoji') }}
+ </h5>
+ </div>
+</template>
diff --git a/app/assets/javascripts/custom_emoji/routes.js b/app/assets/javascripts/custom_emoji/routes.js
new file mode 100644
index 00000000000..2bfbf538571
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/routes.js
@@ -0,0 +1,13 @@
+import IndexComponent from './pages/index.vue';
+import NewComponent from './pages/new.vue';
+
+export default [
+ {
+ path: '/',
+ component: IndexComponent,
+ },
+ {
+ path: '/new',
+ component: NewComponent,
+ },
+];
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 5149dcc5d17..c2fd1249dde 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -18,16 +18,11 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import { Mousetrap } from '~/lib/mousetrap';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '~/notes/event_hub';
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import {
- TREE_LIST_WIDTH_STORAGE_KEY,
- INITIAL_TREE_WIDTH,
- MIN_TREE_WIDTH,
- TREE_HIDE_STATS_WIDTH,
MR_TREE_SHOW_KEY,
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
@@ -56,12 +51,13 @@ import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
import HiddenFilesWarning from './hidden_files_warning.vue';
import NoChanges from './no_changes.vue';
-import TreeList from './tree_list.vue';
import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync';
+import DiffsFileTree from './diffs_file_tree.vue';
export default {
name: 'DiffsApp',
components: {
+ DiffsFileTree,
FindingsDrawer,
DynamicScroller,
DynamicScrollerItem,
@@ -72,9 +68,7 @@ export default {
HiddenFilesWarning,
CollapsedFilesWarning,
CommitWidget,
- TreeList,
GlLoadingIcon,
- PanelResizer,
GlPagination,
GlSprintf,
GlAlert,
@@ -124,11 +118,7 @@ export default {
},
},
data() {
- const treeWidth =
- parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH;
-
return {
- treeWidth,
diffFilesLength: 0,
virtualScrollCurrentIndex: -1,
subscribedToVirtualScrollingEvents: false,
@@ -141,7 +131,6 @@ export default {
}),
...mapState('findingsDrawer', ['activeDrawer']),
...mapState('diffs', [
- 'showTreeList',
'isLoading',
'diffFiles',
'diffViewType',
@@ -194,12 +183,6 @@ export default {
diffsIncomplete() {
return this.flatBlobsList.length !== this.diffFiles.length;
},
- renderFileTree() {
- return this.renderDiffFiles && this.showTreeList;
- },
- hideFileStats() {
- return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
- },
isFullChangeset() {
return this.startVersion === null && this.latestDiff;
},
@@ -273,7 +256,6 @@ export default {
this.subscribeToVirtualScrollingEvents();
},
isLoading: 'adjustView',
- renderFileTree: 'adjustView',
},
mounted() {
if (this.endpointCodequality) {
@@ -376,7 +358,6 @@ export default {
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
- 'cacheTreeListWidth',
'goToFile',
'setShowTreeList',
'navigateToDiffFileIndex',
@@ -590,8 +571,6 @@ export default {
window.location.reload();
},
},
- minTreeWidth: MIN_TREE_WIDTH,
- maxTreeWidth: window.innerWidth / 2,
howToMergeDocsPath: helpPagePath('user/project/merge_requests/reviews/index.md', {
anchor: 'checkout-merge-requests-locally-through-the-head-ref',
}),
@@ -624,22 +603,7 @@ export default {
:data-can-create-note="getNoteableData.current_user.can_create_note"
class="files d-flex gl-mt-2"
>
- <div
- v-if="renderFileTree"
- :style="{ width: `${treeWidth}px` }"
- :class="{ 'is-sidebar-moved': glFeatures.movedMrSidebar }"
- class="diff-tree-list js-diff-tree-list gl-px-5"
- >
- <panel-resizer
- :size.sync="treeWidth"
- :start-size="treeWidth"
- :min-size="$options.minTreeWidth"
- :max-size="$options.maxTreeWidth"
- side="right"
- @resize-end="cacheTreeListWidth"
- />
- <tree-list :hide-file-stats="hideFileStats" />
- </div>
+ <diffs-file-tree :render-diff-files="renderDiffFiles" @toggled="adjustView" />
<div class="col-12 col-md-auto diff-files-holder">
<commit-widget v-if="commit" :commit="commit" :collapsible="false" />
<gl-alert
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 6104a304fbd..86afc6d483a 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -46,7 +46,9 @@ export default {
'removedLines',
]),
toggleFileBrowserTitle() {
- return this.showTreeList ? __('Hide file browser') : __('Show file browser');
+ return this.showTreeList
+ ? __('Hide file browser (or press F)')
+ : __('Show file browser (or press F)');
},
hasChanges() {
return this.diffFiles.length > 0;
diff --git a/app/assets/javascripts/diffs/components/diffs_file_tree.vue b/app/assets/javascripts/diffs/components/diffs_file_tree.vue
new file mode 100644
index 00000000000..bd6ea95ebc7
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diffs_file_tree.vue
@@ -0,0 +1,78 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { Mousetrap } from '~/lib/mousetrap';
+import { keysFor, MR_TOGGLE_FILE_BROWSER } from '~/behaviors/shortcuts/keybindings';
+import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ INITIAL_TREE_WIDTH,
+ MIN_TREE_WIDTH,
+ TREE_HIDE_STATS_WIDTH,
+ TREE_LIST_WIDTH_STORAGE_KEY,
+} from '../constants';
+import TreeList from './tree_list.vue';
+
+export default {
+ name: 'DiffsFileTree',
+ components: { TreeList, PanelResizer },
+ mixins: [glFeatureFlagsMixin()],
+ minTreeWidth: MIN_TREE_WIDTH,
+ maxTreeWidth: window.innerWidth / 2,
+ props: {
+ renderDiffFiles: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ const treeWidth =
+ parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH;
+
+ return {
+ treeWidth,
+ };
+ },
+ computed: {
+ ...mapState('diffs', ['showTreeList']),
+ renderFileTree() {
+ return this.renderDiffFiles && this.showTreeList;
+ },
+ hideFileStats() {
+ return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
+ },
+ },
+ watch: {
+ renderFileTree() {
+ this.$emit('toggled');
+ },
+ },
+ mounted() {
+ Mousetrap.bind(keysFor(MR_TOGGLE_FILE_BROWSER), this.toggleTreeList);
+ },
+ beforeDestroy() {
+ Mousetrap.unbind(keysFor(MR_TOGGLE_FILE_BROWSER), this.toggleTreeList);
+ },
+ methods: {
+ ...mapActions('diffs', ['cacheTreeListWidth', 'toggleTreeList']),
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="renderFileTree"
+ :style="{ width: `${treeWidth}px` }"
+ :class="{ 'is-sidebar-moved': glFeatures.movedMrSidebar }"
+ class="diff-tree-list gl-px-5"
+ >
+ <panel-resizer
+ :size.sync="treeWidth"
+ :start-size="treeWidth"
+ :min-size="$options.minTreeWidth"
+ :max-size="$options.maxTreeWidth"
+ side="right"
+ @resize-end="cacheTreeListWidth"
+ />
+ <tree-list :hide-file-stats="hideFileStats" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 4543d6db375..bbc602aedf6 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -696,6 +696,10 @@ export const setShowTreeList = ({ commit }, { showTreeList, saving = true }) =>
}
};
+export const toggleTreeList = ({ state, commit }) => {
+ commit(types.SET_SHOW_TREE_LIST, !state.showTreeList);
+};
+
export const openDiffFileCommentForm = ({ commit, getters }, formData) => {
const form = getters.getCommentFormForDiffFile(formData.fileHash);
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index f119668048d..70ce5c8d099 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -217,29 +217,24 @@ export default {
@change="setOverride"
/>
- <section v-if="showHelpHtml" class="gl-lg-display-flex gl-justify-content-end gl-mb-6">
+ <!-- helpHtml is trusted input -->
+ <section v-if="showHelpHtml" class="gl-mb-6">
<!-- helpHtml is trusted input -->
- <div
- v-safe-html:[$options.helpHtmlConfig]="helpHtml"
- data-testid="help-html"
- class="gl-flex-basis-two-thirds"
- ></div>
+ <div v-safe-html:[$options.helpHtmlConfig]="helpHtml" data-testid="help-html"></div>
</section>
- <section v-if="!hasSections" class="gl-lg-display-flex gl-justify-content-end">
- <div class="gl-flex-basis-two-thirds">
- <active-checkbox
- v-if="propsSource.showActive"
- :key="`${currentKey}-active-checkbox`"
- @toggle-integration-active="onToggleIntegrationState"
- />
- <trigger-fields
- v-if="propsSource.triggerEvents.length"
- :key="`${currentKey}-trigger-fields`"
- :events="propsSource.triggerEvents"
- :type="propsSource.type"
- />
- </div>
+ <section v-if="!hasSections">
+ <active-checkbox
+ v-if="propsSource.showActive"
+ :key="`${currentKey}-active-checkbox`"
+ @toggle-integration-active="onToggleIntegrationState"
+ />
+ <trigger-fields
+ v-if="propsSource.triggerEvents.length"
+ :key="`${currentKey}-trigger-fields`"
+ :events="propsSource.triggerEvents"
+ :type="propsSource.type"
+ />
</section>
<template v-if="hasSections">
@@ -254,22 +249,19 @@ export default {
/>
</template>
- <section v-if="hasFieldsWithoutSection" class="gl-lg-display-flex gl-justify-content-end">
- <div class="gl-flex-basis-two-thirds">
- <dynamic-field
- v-for="field in fieldsWithoutSection"
- :key="`${currentKey}-${field.name}`"
- v-bind="field"
- :is-validated="isValidated"
- :data-qa-selector="`${field.name}_div`"
- />
- </div>
+ <section v-if="hasFieldsWithoutSection">
+ <dynamic-field
+ v-for="field in fieldsWithoutSection"
+ :key="`${currentKey}-${field.name}`"
+ v-bind="field"
+ :is-validated="isValidated"
+ :data-qa-selector="`${field.name}_div`"
+ />
</section>
<integration-form-actions
v-if="isEditable"
:has-sections="hasSections"
- :class="{ 'gl-lg-display-flex gl-justify-content-end': !hasSections }"
:is-saving="isSaving"
:is-testing="isTesting"
:is-resetting="isResetting"
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue
index e5ad5149cf7..a9d73caf94b 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue
@@ -69,75 +69,69 @@ export default {
};
</script>
<template>
- <section>
- <div :class="{ 'gl-flex-basis-two-thirds': !hasSections }">
- <div
- class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between"
+ <section class="gl-lg-display-flex gl-justify-content-space-between">
+ <div>
+ <template v-if="isInstanceOrGroupLevel">
+ <gl-button
+ v-gl-modal.confirmSaveIntegration
+ category="primary"
+ variant="confirm"
+ :loading="isSaving"
+ :disabled="disableButtons"
+ data-testid="save-button"
+ data-qa-selector="save_changes_button"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <confirmation-modal @submit="onSaveClick" />
+ </template>
+ <gl-button
+ v-else
+ category="primary"
+ variant="confirm"
+ type="submit"
+ :loading="isSaving"
+ :disabled="disableButtons"
+ data-testid="save-button"
+ data-qa-selector="save_changes_button"
+ @click.prevent="onSaveClick"
>
- <div>
- <template v-if="isInstanceOrGroupLevel">
- <gl-button
- v-gl-modal.confirmSaveIntegration
- category="primary"
- variant="confirm"
- :loading="isSaving"
- :disabled="disableButtons"
- data-testid="save-button"
- data-qa-selector="save_changes_button"
- >
- {{ __('Save changes') }}
- </gl-button>
- <confirmation-modal @submit="onSaveClick" />
- </template>
- <gl-button
- v-else
- category="primary"
- variant="confirm"
- type="submit"
- :loading="isSaving"
- :disabled="disableButtons"
- data-testid="save-button"
- data-qa-selector="save_changes_button"
- @click.prevent="onSaveClick"
- >
- {{ __('Save changes') }}
- </gl-button>
+ {{ __('Save changes') }}
+ </gl-button>
- <gl-button
- v-if="showTestButton"
- category="secondary"
- variant="confirm"
- :loading="isTesting"
- :disabled="disableButtons"
- data-testid="test-button"
- @click.prevent="onTestClick"
- >
- {{ __('Test settings') }}
- </gl-button>
+ <gl-button
+ v-if="showTestButton"
+ category="secondary"
+ variant="confirm"
+ :loading="isTesting"
+ :disabled="disableButtons"
+ data-testid="test-button"
+ @click.prevent="onTestClick"
+ >
+ {{ __('Test settings') }}
+ </gl-button>
- <gl-button
- :href="propsSource.cancelPath"
- data-testid="cancel-button"
- :disabled="disableButtons"
- >{{ __('Cancel') }}</gl-button
- >
- </div>
+ <gl-button
+ :href="propsSource.cancelPath"
+ data-testid="cancel-button"
+ :disabled="disableButtons"
+ >{{ __('Cancel') }}</gl-button
+ >
+ </div>
- <template v-if="showResetButton">
- <gl-button
- v-gl-modal.confirmResetIntegration
- category="tertiary"
- variant="danger"
- :loading="isResetting"
- :disabled="disableButtons"
- data-testid="reset-button"
- >
- {{ __('Reset') }}
- </gl-button>
+ <template v-if="showResetButton">
+ <gl-button
+ v-gl-modal.confirmResetIntegration
+ category="tertiary"
+ variant="danger"
+ :loading="isResetting"
+ :disabled="disableButtons"
+ data-testid="reset-button"
+ >
+ {{ __('Reset') }}
+ </gl-button>
- <reset-confirmation-modal @reset="onResetClick" />
- </template>
- </div>
- </div>
+ <reset-confirmation-modal @reset="onResetClick" />
+ </template>
</section>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
index 5335b7b6ee2..85897652d34 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
@@ -63,36 +63,29 @@ export default {
};
</script>
<template>
- <section class="gl-lg-display-flex">
- <div class="gl-flex-basis-third gl-mr-4">
- <h4 class="gl-mt-0">
- {{ section.title
- }}<gl-badge
- v-if="section.plan"
- :href="propsSource.aboutPricingUrl"
- target="_blank"
- rel="noopener noreferrer"
- variant="tier"
- icon="license"
- class="gl-ml-3"
- >
- {{ $options.billingPlanNames[section.plan] }}
- </gl-badge>
- </h4>
- <p v-safe-html="section.description"></p>
- </div>
+ <section>
+ <h4 class="gl-mt-0">
+ {{ section.title
+ }}<gl-badge
+ v-if="section.plan"
+ :href="propsSource.aboutPricingUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ variant="tier"
+ icon="license"
+ class="gl-ml-3"
+ >
+ {{ $options.billingPlanNames[section.plan] }}
+ </gl-badge>
+ </h4>
+ <p v-safe-html="section.description"></p>
- <div
- v-if="$options.integrationFormSectionComponents[section.type]"
- class="gl-flex-basis-two-thirds"
- >
- <component
- :is="$options.integrationFormSectionComponents[section.type]"
- :fields="fieldsForSection(section)"
- :is-validated="isValidated"
- @toggle-integration-active="$emit('toggle-integration-active', $event)"
- @request-jira-issue-types="$emit('request-jira-issue-types', $event)"
- />
- </div>
+ <component
+ :is="$options.integrationFormSectionComponents[section.type]"
+ :fields="fieldsForSection(section)"
+ :is-validated="isValidated"
+ @toggle-integration-active="$emit('toggle-integration-active', $event)"
+ @request-jira-issue-types="$emit('request-jira-issue-types', $event)"
+ />
</section>
</template>
diff --git a/app/assets/javascripts/lib/mousetrap.js b/app/assets/javascripts/lib/mousetrap.js
index ef3f54ec314..297ca00f4e4 100644
--- a/app/assets/javascripts/lib/mousetrap.js
+++ b/app/assets/javascripts/lib/mousetrap.js
@@ -56,4 +56,6 @@ export const clearStopCallbacksForTests = () => {
additionalStopCallbacks.length = 0;
};
+export const MOUSETRAP_COPY_KEYBOARD_SHORTCUT = 'mod+c';
+
export { Mousetrap };
diff --git a/app/assets/javascripts/oauth_application/components/oauth_secret.vue b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
index c4a928c5e07..12374bcf261 100644
--- a/app/assets/javascripts/oauth_application/components/oauth_secret.vue
+++ b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
@@ -81,6 +81,7 @@ export default {
v-if="secret"
:copy-button-title="$options.COPY_SECRET"
:value="secret"
+ readonly
class="gl-mt-n3 gl-mb-0"
>
<template #description>
diff --git a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue
new file mode 100644
index 00000000000..4f95b140a23
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue
@@ -0,0 +1,65 @@
+<script>
+import { computePosition, autoUpdate, flip } from '@floating-ui/dom';
+import NavItem from './nav_item.vue';
+
+export default {
+ name: 'FlyoutMenu',
+ components: { NavItem },
+ props: {
+ targetId: {
+ type: String,
+ required: true,
+ },
+ items: {
+ type: Array,
+ required: true,
+ },
+ },
+ cleanupFunction: undefined,
+ mounted() {
+ const target = document.querySelector(`#${this.targetId}`);
+ const flyout = document.querySelector(`#${this.targetId}-flyout`);
+
+ function updatePosition() {
+ return computePosition(target, flyout, {
+ middleware: [flip()],
+ placement: 'right-start',
+ strategy: 'fixed',
+ }).then(({ x, y }) => {
+ Object.assign(flyout.style, {
+ left: `${x}px`,
+ top: `${y}px`,
+ });
+ });
+ }
+
+ this.$options.cleanupFunction = autoUpdate(target, flyout, updatePosition);
+ },
+ beforeUnmount() {
+ this.$options.cleanupFunction();
+ },
+};
+</script>
+
+<template>
+ <div
+ :id="`${targetId}-flyout`"
+ class="gl-fixed gl-pl-3 gl-z-index-9999"
+ @mouseover="$emit('mouseover')"
+ @mouseleave="$emit('mouseleave')"
+ >
+ <ul
+ v-if="items.length > 0"
+ class="gl-min-w-20 gl-max-w-34 gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-100 gl-shadow gl-bg-white gl-p-2 gl-pb-1 gl-list-style-none"
+ >
+ <nav-item
+ v-for="item of items"
+ :key="item.id"
+ :item="item"
+ :is-flyout="true"
+ @pin-add="(itemId) => $emit('pin-add', itemId)"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue
index 73a899eeb83..37a6ab0122b 100644
--- a/app/assets/javascripts/super_sidebar/components/menu_section.vue
+++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue
@@ -2,6 +2,7 @@
import { kebabCase } from 'lodash';
import { GlCollapse, GlIcon } from '@gitlab/ui';
import NavItem from './nav_item.vue';
+import FlyoutMenu from './flyout_menu.vue';
export default {
name: 'MenuSection',
@@ -9,6 +10,7 @@ export default {
GlCollapse,
GlIcon,
NavItem,
+ FlyoutMenu,
},
props: {
item: {
@@ -30,10 +32,17 @@ export default {
required: false,
default: 'div',
},
+ hasFlyout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
isExpanded: Boolean(this.expanded || this.item.is_active),
+ isMouseOverSection: false,
+ isMouseOverFlyout: false,
};
},
computed: {
@@ -45,6 +54,9 @@ export default {
};
},
collapseIcon() {
+ if (this.hasFlyout) {
+ return this.isExpanded ? 'chevron-down' : 'chevron-right';
+ }
return this.isExpanded ? 'chevron-up' : 'chevron-down';
},
computedLinkClasses() {
@@ -58,12 +70,20 @@ export default {
itemId() {
return kebabCase(this.item.title);
},
+ isMouseOver() {
+ return this.isMouseOverSection || this.isMouseOverFlyout;
+ },
},
watch: {
isExpanded(newIsExpanded) {
this.$emit('collapse-toggle', newIsExpanded);
},
},
+ methods: {
+ handlePointerover(e) {
+ this.isMouseOverSection = e.pointerType === 'mouse';
+ },
+ },
};
</script>
@@ -71,12 +91,15 @@ export default {
<component :is="tag">
<hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" />
<button
+ :id="`menu-section-button-${itemId}`"
class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-2 gl-py-2 gl-px-3 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus"
:class="computedLinkClasses"
data-qa-selector="menu_section_button"
:data-qa-section-name="item.title"
v-bind="buttonProps"
@click="isExpanded = !isExpanded"
+ @pointerover="handlePointerover"
+ @pointerleave="isMouseOverSection = false"
>
<span
:class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']"
@@ -99,6 +122,17 @@ export default {
</span>
</button>
+ <flyout-menu
+ v-if="hasFlyout"
+ v-show="isMouseOver && !isExpanded"
+ :target-id="`menu-section-button-${itemId}`"
+ :items="item.items"
+ @mouseover="isMouseOverFlyout = true"
+ @mouseleave="isMouseOverFlyout = false"
+ @pin-add="(itemId) => $emit('pin-add', itemId)"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+
<gl-collapse
:id="itemId"
v-model="isExpanded"
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
index c1e1f64dbc1..842893af931 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -56,6 +56,11 @@ export default {
required: false,
default: false,
},
+ isFlyout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
pillData() {
@@ -104,6 +109,7 @@ export default {
return {
'gl-px-2 gl-mx-2 gl-line-height-normal': this.isSubitem,
'gl-px-3': !this.isSubitem,
+ 'gl-pl-5!': this.isFlyout,
[this.item.link_classes]: this.item.link_classes,
...this.linkClasses,
};
@@ -133,7 +139,7 @@ export default {
style="width: 3px; border-radius: 3px; margin-right: 1px"
data-testid="active-indicator"
></div>
- <div class="gl-flex-shrink-0 gl-w-6 gl-display-flex">
+ <div v-if="!isFlyout" class="gl-flex-shrink-0 gl-w-6 gl-display-flex">
<slot name="icon">
<gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" />
<gl-icon
diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
index ccd739c8bb1..a6cc70963df 100644
--- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue
+++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
@@ -28,6 +28,11 @@ export default {
required: false,
default: false,
},
+ hasFlyout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -40,7 +45,12 @@ export default {
return this.items.some((item) => item.is_active);
},
sectionItem() {
- return { title: this.$options.i18n.pinned, icon: 'thumbtack', is_active: this.isActive };
+ return {
+ title: this.$options.i18n.pinned,
+ icon: 'thumbtack',
+ is_active: this.isActive,
+ items: this.draggableItems,
+ };
},
itemIds() {
return this.draggableItems.map((item) => item.id);
@@ -75,7 +85,9 @@ export default {
:item="sectionItem"
:expanded="expanded"
:separated="separated"
+ :has-flyout="hasFlyout"
@collapse-toggle="expanded = !expanded"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
>
<draggable
v-if="items.length > 0"
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
index 287e4f57d01..86fe5c9ad5c 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
@@ -1,7 +1,9 @@
<script>
import * as Sentry from '@sentry/browser';
+import { GlBreakpointInstance, breakpoints } from '@gitlab/ui/dist/utils';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PANELS_WITH_PINS } from '../constants';
import NavItem from './nav_item.vue';
import PinnedSection from './pinned_section.vue';
@@ -14,6 +16,7 @@ export default {
NavItem,
PinnedSection,
},
+ mixins: [glFeatureFlagsMixin()],
provide() {
return {
@@ -49,6 +52,8 @@ export default {
data() {
return {
+ showFlyoutMenus: false,
+
// This is used as a provide and injected into the nav items.
// Note: It has to be an object to be reactive.
changedPinnedItemIds: { ids: this.pinnedItemIds },
@@ -98,6 +103,15 @@ export default {
return this.staticItems.length > 0;
},
},
+ mounted() {
+ if (this.glFeatures.superSidebarFlyoutMenus) {
+ this.decideFlyoutState();
+ window.addEventListener('resize', this.decideFlyoutState);
+ }
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.decideFlyoutState);
+ },
methods: {
createPin(itemId) {
this.changedPinnedItemIds.ids.push(itemId);
@@ -137,6 +151,9 @@ export default {
isSection(navItem) {
return navItem.items?.length;
},
+ decideFlyoutState() {
+ this.showFlyoutMenus = GlBreakpointInstance.windowWidth() >= breakpoints.md;
+ },
},
};
</script>
@@ -150,6 +167,7 @@ export default {
v-if="supportsPins"
separated
:items="pinnedItems"
+ :has-flyout="showFlyoutMenus"
@pin-remove="destroyPin"
@pin-reorder="movePin"
/>
@@ -166,6 +184,7 @@ export default {
:key="item.id"
:item="item"
:separated="item.separated"
+ :has-flyout="showFlyoutMenus"
@pin-add="createPin"
@pin-remove="destroyPin"
/>
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
index 377f1e7c136..531ed5fe0ea 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
@@ -8,16 +8,21 @@ export default {
const defaultProps = {
value: 'hR8x1fuJbzwu5uFKLf9e',
formInputGroupProps: { class: 'gl-form-input-xl' },
+ readonly: false,
};
const Template = (args, { argTypes }) => ({
components: { InputCopyToggleVisibility },
+ data() {
+ return { value: args.value };
+ },
props: Object.keys(argTypes),
template: `<input-copy-toggle-visibility
- :value="value"
+ v-model="value"
:initial-visibility="initialVisibility"
:show-toggle-visibility-button="showToggleVisibilityButton"
:show-copy-button="showCopyButton"
+ :readonly="readonly"
:form-input-group-props="formInputGroupProps"
:copy-button-title="copyButtonTitle"
/>`,
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index dea279890b1..c371c1aeff9 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -8,6 +8,7 @@ import {
} from '@gitlab/ui';
import { __ } from '~/locale';
+import { Mousetrap, MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
@@ -52,6 +53,11 @@ export default {
required: false,
default: __('Copy'),
},
+ readonly: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
formInputGroupProps: {
type: Object,
required: false,
@@ -61,6 +67,12 @@ export default {
},
},
data() {
+ if (!this.readonly && !this.value) {
+ return {
+ valueIsVisible: true,
+ };
+ }
+
return {
valueIsVisible: this.initialVisibility,
};
@@ -77,33 +89,60 @@ export default {
computedValueIsVisible() {
return !this.showToggleVisibilityButton || this.valueIsVisible;
},
- displayedValue() {
- return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20);
+ inputType() {
+ return this.computedValueIsVisible ? 'text' : 'password';
},
},
+ mounted() {
+ this.$options.mousetrap = new Mousetrap(this.$refs.input.$el);
+ this.$options.mousetrap.bind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT, this.handleFormInputCopy);
+ },
+ beforeDestroy() {
+ this.$options.mousetrap?.unbind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT);
+ },
+
methods: {
handleToggleVisibilityButtonClick() {
this.valueIsVisible = !this.valueIsVisible;
this.$emit('visibility-change', this.valueIsVisible);
},
- handleClick() {
- this.$refs.input.$el.select();
+ async handleClick() {
+ if (this.readonly) {
+ this.$refs.input.$el.select();
+ } else if (!this.valueIsVisible) {
+ const { selectionStart, selectionEnd } = this.$refs.input.$el;
+ this.handleToggleVisibilityButtonClick();
+
+ setTimeout(() => {
+ // When the input type is changed from 'password'' to 'text', cursor position is reset in some browsers.
+ // This makes clicking to edit difficult due to typing in unexpected location, so we preserve the cursor position / selection
+ this.$refs.input.$el.setSelectionRange(selectionStart, selectionEnd);
+ }, 0);
+ }
},
handleCopyButtonClick() {
this.$emit('copy');
},
- handleFormInputCopy(event) {
- this.handleCopyButtonClick();
-
+ async handleFormInputCopy() {
+ // Value will be copied by native browser behavior
if (this.computedValueIsVisible) {
return;
}
- event.clipboardData.setData('text/plain', this.value);
- event.preventDefault();
+ try {
+ // user is trying to copy from the password input, set their clipboard for them
+ await navigator.clipboard?.writeText(this.value);
+ this.handleCopyButtonClick();
+ } catch (e) {
+ // Nothing we can do here, best effort to set clipboard value
+ }
+ },
+ handleInput(newValue) {
+ this.$emit('input', newValue);
},
},
+ mousetrap: null,
};
</script>
<template>
@@ -111,11 +150,12 @@ export default {
<gl-form-input-group>
<gl-form-input
ref="input"
- readonly
+ :readonly="readonly"
class="gl-font-monospace! gl-cursor-default!"
v-bind="formInputGroupProps"
- :value="displayedValue"
- @copy="handleFormInputCopy"
+ :value="value"
+ :type="inputType"
+ @input="handleInput"
@click="handleClick"
/>
diff --git a/app/assets/stylesheets/framework/new_card.scss b/app/assets/stylesheets/framework/new_card.scss
index ef8f5cc1d1b..48a834858c6 100644
--- a/app/assets/stylesheets/framework/new_card.scss
+++ b/app/assets/stylesheets/framework/new_card.scss
@@ -91,4 +91,47 @@
@include gl-border-gray-100;
@include gl-rounded-base;
}
+
+ // Table adjustments
+ @mixin new-card-table-adjustments {
+ tbody > tr {
+ > td[data-label] {
+ @include gl-border-left-0;
+ @include gl-border-l-none;
+ @include gl-border-right-0;
+ @include gl-border-r-none;
+ }
+
+ > th {
+ @include gl-border-t-1;
+ @include gl-border-b-0;
+ }
+
+ &::after {
+ @include gl-bg-white;
+ }
+
+ &:last-child::after {
+ @include gl-display-none;
+ }
+ }
+ }
+
+ table.b-table-stacked-sm,
+ table.b-table-stacked-md {
+ @include gl-mt-n1;
+ @include gl-mb-n2;
+ }
+
+ table.gl-table.b-table.b-table-stacked-sm {
+ @include gl-media-breakpoint-down(sm) {
+ @include new-card-table-adjustments;
+ }
+ }
+
+ table.gl-table.b-table.b-table-stacked-md {
+ @include gl-media-breakpoint-down(md) {
+ @include new-card-table-adjustments;
+ }
+ }
}
diff --git a/app/finders/abuse_reports_finder.rb b/app/finders/abuse_reports_finder.rb
index 6a6d0413194..43cebd16d92 100644
--- a/app/finders/abuse_reports_finder.rb
+++ b/app/finders/abuse_reports_finder.rb
@@ -3,9 +3,13 @@
class AbuseReportsFinder
attr_reader :params, :reports
- DEFAULT_STATUS_FILTER = 'open'
- DEFAULT_SORT = 'created_at_desc'
- ALLOWED_SORT = [DEFAULT_SORT, *%w[created_at_asc updated_at_desc updated_at_asc]].freeze
+ STATUS_OPEN = 'open'
+
+ DEFAULT_SORT_STATUS_CLOSED = 'created_at_desc'
+ ALLOWED_SORT = [DEFAULT_SORT_STATUS_CLOSED, *%w[created_at_asc updated_at_desc updated_at_asc]].freeze
+
+ DEFAULT_SORT_STATUS_OPEN = 'number_of_reports_desc'
+ SORT_BY_COUNT = [DEFAULT_SORT_STATUS_OPEN].freeze
def initialize(params = {})
@params = params
@@ -14,6 +18,7 @@ class AbuseReportsFinder
def execute
filter_reports
+ aggregate_reports
sort_reports
reports.with_users.page(params[:page])
@@ -22,20 +27,28 @@ class AbuseReportsFinder
private
def filter_reports
- filter_by_user_id
+ if Feature.disabled?(:abuse_reports_list)
+ filter_by_user_id
+ return
+ end
+ filter_by_status
filter_by_user
filter_by_reporter
- filter_by_status
filter_by_category
end
+ def filter_by_user_id
+ return unless params[:user_id].present?
+
+ @reports = @reports.by_user_id(params[:user_id])
+ end
+
def filter_by_status
- return unless Feature.enabled?(:abuse_reports_list)
return unless params[:status].present?
status = params[:status]
- status = DEFAULT_STATUS_FILTER unless status.in?(AbuseReport.statuses.keys)
+ status = STATUS_OPEN unless status.in?(AbuseReport.statuses.keys)
case status
when 'open'
@@ -69,10 +82,13 @@ class AbuseReportsFinder
@reports = @reports.by_reporter_id(user_id)
end
- def filter_by_user_id
- return unless params[:user_id].present?
+ def sort_key
+ sort_key = params[:sort]
- @reports = @reports.by_user_id(params[:user_id])
+ return sort_key if sort_key.in?(ALLOWED_SORT + SORT_BY_COUNT)
+ return DEFAULT_SORT_STATUS_OPEN if status_open?
+
+ DEFAULT_SORT_STATUS_CLOSED
end
def sort_reports
@@ -81,13 +97,31 @@ class AbuseReportsFinder
return
end
- sort_by = params[:sort]
- sort_by = DEFAULT_SORT unless sort_by.in?(ALLOWED_SORT)
+ # let sub_query in aggregate_reports do the sorting if sorting by number of reports
+ return if sort_key.in?(SORT_BY_COUNT)
- @reports = @reports.order_by(sort_by)
+ @reports = @reports.order_by(sort_key)
end
def find_user_id(username)
User.by_username(username).pick(:id)
end
+
+ def status_open?
+ return unless Feature.enabled?(:abuse_reports_list) && params[:status].present?
+
+ status = params[:status]
+ status = STATUS_OPEN unless status.in?(AbuseReport.statuses.keys)
+
+ status == STATUS_OPEN
+ end
+
+ def aggregate_reports
+ if status_open?
+ sort_by_count = sort_key.in?(SORT_BY_COUNT)
+ @reports = @reports.aggregated_by_user_and_category(sort_by_count)
+ end
+
+ @reports
+ end
end
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 1d2eee82827..210fd85ec7b 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -214,6 +214,24 @@ class AbuseReport < ApplicationRecord
extension_list: valid_image_extensions.to_sentence(last_word_connector: ' or '))
)
end
+
+ def self.aggregated_by_user_and_category(sort_by_count = false)
+ sub_query = self
+ .select('user_id, category, COUNT(id) as count', 'MIN(id) as min')
+ .group(:user_id, :category)
+
+ reports = AbuseReport.with_users
+ .open
+ .select('aggregated.*, status, id, reporter_id, created_at, updated_at')
+ .from(sub_query, :aggregated)
+ .joins('INNER JOIN abuse_reports on aggregated.min = abuse_reports.id')
+
+ if sort_by_count
+ reports.order(count: :desc, created_at: :desc)
+ else
+ reports
+ end
+ end
end
AbuseReport.prepend_mod
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index bd327cfbe7b..97efccf7160 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -23,6 +23,7 @@ module Ci
include IgnorableColumns
ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22'
+ ignore_column :auto_canceled_by_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22'
MAX_OPEN_MERGE_REQUESTS_REFS = 4
diff --git a/app/models/integrations/chat_message/issue_message.rb b/app/models/integrations/chat_message/issue_message.rb
index 1c234630370..dd516362491 100644
--- a/app/models/integrations/chat_message/issue_message.rb
+++ b/app/models/integrations/chat_message/issue_message.rb
@@ -27,7 +27,7 @@ module Integrations
def attachments
return [] unless opened_issue?
- return description if markdown
+ return SlackMarkdownSanitizer.sanitize_slack_link(description) if markdown
description_message
end
@@ -55,7 +55,7 @@ module Integrations
[{
title: issue_title,
title_link: issue_url,
- text: format(description),
+ text: format(SlackMarkdownSanitizer.sanitize_slack_link(description)),
color: '#C95823'
}]
end
diff --git a/app/models/note.rb b/app/models/note.rb
index c78c0a943e1..92d4daeab3e 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -853,7 +853,9 @@ class Note < ApplicationRecord
user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count
else
refs = all_references(user)
- refs.all.any? && refs.all_visible?
+ refs.all
+
+ refs.all_visible?
end
end
diff --git a/app/serializers/admin/abuse_report_entity.rb b/app/serializers/admin/abuse_report_entity.rb
index 58637445e81..22395a2fe91 100644
--- a/app/serializers/admin/abuse_report_entity.rb
+++ b/app/serializers/admin/abuse_report_entity.rb
@@ -7,6 +7,7 @@ module Admin
expose :category
expose :created_at
expose :updated_at
+ expose :count
expose :reported_user do |report|
UserEntity.represent(report.user, only: [:name])
@@ -19,5 +20,11 @@ module Admin
expose :report_path do |report|
admin_abuse_report_path(report)
end
+
+ private
+
+ def count
+ object.has_attribute?(:count) ? object.count : 1
+ end
end
end
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index cd0c9a016a5..a6904495f7c 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -29,7 +29,8 @@
%td.line_content.js-success-lazy-load
.js-code-placeholder
%td.js-error-lazy-load-diff.hidden.diff-loading-error-block
- - button = button_tag(_("Try again"), class: "btn-link gl-button btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button")
+ - button = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button' }) do
+ = _("Try again")
= _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button}
= render "discussions/diff_discussion", discussions: [discussion], expanded: true
- else
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 17e48a95122..7ce914cf660 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -11,7 +11,7 @@
%li.divider
- if can?(current_user, :update_user_status, current_user)
%li
- = render Pajamas::ButtonComponent.new(button_options: { class: 'js-set-status-modal-trigger' }) do
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'menu-item js-set-status-modal-trigger' }) do
- if current_user.status&.busy? || current_user.status&.customized?
= s_('SetStatusModal|Edit status')
- else
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index f1d5a127728..d5193a424ef 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -1,45 +1,70 @@
- is_admin = defined?(admin) ? true : false
-.row.gl-mt-3
- .col-md-4
- = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c|
- - c.with_header do
- = _('SSH Key')
- - c.with_body do
- %ul.content-list
- %li
- %span.light= _('Title:')
- %strong= @key.title
- %li
- %span.light= s_('Profiles|Usage type:')
- %strong= ssh_key_usage_types.invert[@key.usage_type]
- %li
- %span.light= _('Created on:')
- %strong= @key.created_at.to_fs(:medium)
- %li
- %span.light= _('Expires:')
- %strong= @key.expires_at&.to_fs(:medium) || _('Never')
- %li
- %span.light= _('Last used on:')
- %strong= @key.last_used_at&.to_fs(:medium) || _('Never')
- .col-md-8
- = form_errors(@key, type: 'key') unless @key.valid?
- %pre.well-pre
- = @key.key
- = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c|
- - c.with_header do
- = _('Fingerprints')
- - c.with_body do
- %ul.content-list
- %li
- %span.light= 'MD5:'
- %code.key-fingerprint= @key.fingerprint
- - if @key.fingerprint_sha256.present?
- %li
- %span.light= 'SHA256:'
- %code.key-fingerprint= @key.fingerprint_sha256
+%h1.gl-font-size-h-display
+ = s_('Profiles|SSH Key: %{title}').html_safe % { title: @key.title }
- .col-md-12
- .float-right
- - if @key.can_delete?
- = render 'shared/ssh_keys/key_delete', button_data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin))
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0'}) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ .gl-new-card-title
+ = _('Key details')
+ - c.with_body do
+ .table-holder
+ %table.table.b-table.gl-table.b-table-stacked-md.ssh-keys-list
+ %thead
+ %th= s_('Profiles|Usage type')
+ %th= s_('Profiles|Created')
+ %th= s_('Profiles|Last used')
+ %th= s_('Profiles|Expires')
+ %tbody
+ %tr
+ %td{ data: { label: s_('Profiles|Usage type'), testid: 'usage' } }
+ = ssh_key_usage_types.invert[@key.usage_type]
+ %td{ data: { label: s_('Profiles|Created'), testid: 'created' } }
+ = @key.created_at.to_fs(:medium)
+ %td{ data: { label: s_('Profiles|Last used'), testid: 'last-used' } }
+ = @key.last_used_at&.to_fs(:medium) || _('Never')
+ %td{ data: { label: s_('Profiles|Expires'), testid: 'expires' } }
+ - if @key.expired?
+ %span.gl-text-red-500
+ = s_('Profiles|Expired')
+ = @key.expires_at&.to_fs(:medium)
+ - elsif @key.expires_at
+ = @key.expires_at&.to_fs(:medium)
+ - else
+ = _('Never')
+
+.gl-mt-5
+ = form_errors(@key, type: 'key') unless @key.valid?
+
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0 gl-overflow-hidden'}) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ .gl-new-card-title
+ = _('SSH Key')
+ - c.with_body do
+ .gl-display-flex
+ %pre.well-pre.gl-pl-5.gl-mb-0.gl-border-0
+ = @key.key
+ = clipboard_button(title: s_('Profiles|Copy SSH key'), text: @key.key, class: 'gl-bg-gray-10 gl-px-3! gl-border-none! gl-rounded-top-left-none! gl-rounded-bottom-left-none!')
+
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0'}) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ .gl-new-card-title
+ = _('Fingerprints')
+ - c.with_body do
+ .table-holder
+ %table.table.b-table.gl-table.b-table-stacked-md
+ %tbody
+ %tr
+ %th= _('MD5')
+ %td.gl-font-monospace.key-fingerprint= @key.fingerprint
+ - if @key.fingerprint_sha256.present?
+ %tr
+ %th= _('SHA256')
+ %td.gl-font-monospace.key-fingerprint= @key.fingerprint_sha256
+
+.gl-mt-5.gl-float-right
+ - if @key.can_delete?
+ = render 'shared/ssh_keys/key_delete', button_data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin))
diff --git a/app/views/projects/settings/integrations/_form.html.haml b/app/views/projects/settings/integrations/_form.html.haml
index 39dfd410727..97c7729de44 100644
--- a/app/views/projects/settings/integrations/_form.html.haml
+++ b/app/views/projects/settings/integrations/_form.html.haml
@@ -14,10 +14,10 @@
- if integration.to_param === 'slack'
= render 'shared/integrations/slack_notifications_deprecation_alert'
-%h2.gl-mb-4
+%h2.gl-mb-0.gl-display-flex.gl-align-items-center.gl-gap-3
= integration.title
- if integration.operating?
- = sprite_icon('check', css_class: 'gl-text-green-500')
+ = render Pajamas::BadgeComponent.new(s_('FeatureFlags|Active'), variant: 'success')
= render 'shared/integration_settings', integration: integration
- if lookup_context.template_exists?('show', "shared/integrations/#{integration.to_param}", true)
diff --git a/app/views/shared/ssh_keys/_key_delete.html.haml b/app/views/shared/ssh_keys/_key_delete.html.haml
index 80cd23989a0..c969520d7e9 100644
--- a/app/views/shared/ssh_keys/_key_delete.html.haml
+++ b/app/views/shared/ssh_keys/_key_delete.html.haml
@@ -1,5 +1,4 @@
- category = local_assigns[:category] || :primary
-.gl-p-2
- = render Pajamas::ButtonComponent.new(variant: :danger, category: category, button_options: { class: 'js-confirm-modal-button', data: button_data }) do
- = _('Delete')
+= render Pajamas::ButtonComponent.new(variant: :danger, category: category, button_options: { class: 'js-confirm-modal-button', data: button_data }) do
+ = _('Delete')
diff --git a/config/feature_flags/development/mr_activity_filters.yml b/config/feature_flags/development/mr_activity_filters.yml
index fcad25e3ba8..ae3a193047b 100644
--- a/config/feature_flags/development/mr_activity_filters.yml
+++ b/config/feature_flags/development/mr_activity_filters.yml
@@ -1,8 +1,8 @@
---
name: mr_activity_filters
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/115383
-rollout_issue_url:
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/412432
milestone: '15.11'
type: development
group: group::code review
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/restrict_special_characters_in_namespace_path.yml b/config/feature_flags/development/restrict_special_characters_in_namespace_path.yml
index fb04e8310e5..c46d56d905a 100644
--- a/config/feature_flags/development/restrict_special_characters_in_namespace_path.yml
+++ b/config/feature_flags/development/restrict_special_characters_in_namespace_path.yml
@@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/390954
milestone: '15.9'
type: development
group: group::tenant scale
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/track_work_items_activity.yml b/config/feature_flags/development/super_sidebar_flyout_menus.yml
index 3727bca1078..6bec0ef60df 100644
--- a/config/feature_flags/development/track_work_items_activity.yml
+++ b/config/feature_flags/development/super_sidebar_flyout_menus.yml
@@ -1,8 +1,8 @@
---
-name: track_work_items_activity
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80532
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/352903
-milestone: '14.9'
+name: super_sidebar_flyout_menus
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124863
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/417237
+milestone: '16.2'
type: development
-group: group::project management
-default_enabled: true
+group: group::foundations
+default_enabled: false
diff --git a/config/gitlab_loose_foreign_keys.yml b/config/gitlab_loose_foreign_keys.yml
index dfc4861d1f7..5b2311da550 100644
--- a/config/gitlab_loose_foreign_keys.yml
+++ b/config/gitlab_loose_foreign_keys.yml
@@ -207,6 +207,9 @@ dast_scanner_profiles_builds:
- table: ci_builds
column: ci_build_id
on_delete: async_delete
+ - table: p_ci_builds
+ column: ci_build_id
+ on_delete: async_delete
dast_scanner_profiles_tags:
- table: tags
column: tag_id
@@ -215,6 +218,9 @@ dast_site_profiles_builds:
- table: ci_builds
column: ci_build_id
on_delete: async_delete
+ - table: p_ci_builds
+ column: ci_build_id
+ on_delete: async_delete
dast_site_profiles_pipelines:
- table: ci_pipelines
column: ci_pipeline_id
@@ -247,6 +253,9 @@ ml_candidates:
- table: ci_builds
column: ci_build_id
on_delete: async_nullify
+ - table: p_ci_builds
+ column: ci_build_id
+ on_delete: async_nullify
namespaces:
- table: organizations
column: organization_id
@@ -271,10 +280,16 @@ pages_deployments:
- table: ci_builds
column: ci_build_id
on_delete: async_nullify
+ - table: p_ci_builds
+ column: ci_build_id
+ on_delete: async_nullify
requirements_management_test_reports:
- table: ci_builds
column: build_id
on_delete: async_nullify
+ - table: p_ci_builds
+ column: build_id
+ on_delete: async_nullify
sbom_occurrences:
- table: ci_pipelines
column: pipeline_id
@@ -283,10 +298,16 @@ security_scans:
- table: ci_builds
column: build_id
on_delete: async_delete
+ - table: p_ci_builds
+ column: build_id
+ on_delete: async_delete
terraform_state_versions:
- table: ci_builds
column: ci_build_id
on_delete: async_nullify
+ - table: p_ci_builds
+ column: ci_build_id
+ on_delete: async_nullify
vulnerability_feedback:
- table: ci_pipelines
column: pipeline_id
diff --git a/config/metrics/counts_28d/20220222215951_xmau_plan.yml b/config/metrics/counts_28d/20220222215951_xmau_plan.yml
index a70ee00e254..ed584ae20a2 100644
--- a/config/metrics/counts_28d/20220222215951_xmau_plan.yml
+++ b/config/metrics/counts_28d/20220222215951_xmau_plan.yml
@@ -9,7 +9,7 @@ status: active
milestone: '14.9'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81336
time_frame: 28d
-instrumentation_class: WorkItemsActivityAggregatedMetric
+instrumentation_class: AggregatedMetric
data_source: redis_hll
options:
aggregate:
diff --git a/config/metrics/counts_28d/20220222215952_xmau_project_management.yml b/config/metrics/counts_28d/20220222215952_xmau_project_management.yml
index fa4ca4719cb..574d06534d7 100644
--- a/config/metrics/counts_28d/20220222215952_xmau_project_management.yml
+++ b/config/metrics/counts_28d/20220222215952_xmau_project_management.yml
@@ -9,7 +9,7 @@ status: active
milestone: '14.9'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81336
time_frame: 28d
-instrumentation_class: WorkItemsActivityAggregatedMetric
+instrumentation_class: AggregatedMetric
data_source: redis_hll
options:
aggregate:
diff --git a/config/metrics/counts_28d/20220222215955_users_work_items.yml b/config/metrics/counts_28d/20220222215955_users_work_items.yml
index 3ba2f8b0f50..90eefcf6ed8 100644
--- a/config/metrics/counts_28d/20220222215955_users_work_items.yml
+++ b/config/metrics/counts_28d/20220222215955_users_work_items.yml
@@ -9,7 +9,7 @@ status: active
milestone: '14.9'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81336
time_frame: 28d
-instrumentation_class: WorkItemsActivityAggregatedMetric
+instrumentation_class: AggregatedMetric
data_source: redis_hll
options:
aggregate:
diff --git a/config/metrics/counts_7d/20220222215851_xmau_plan.yml b/config/metrics/counts_7d/20220222215851_xmau_plan.yml
index 4443e46fb8d..e9c5a177380 100644
--- a/config/metrics/counts_7d/20220222215851_xmau_plan.yml
+++ b/config/metrics/counts_7d/20220222215851_xmau_plan.yml
@@ -8,7 +8,7 @@ value_type: number
status: active
milestone: '14.9'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81336
-instrumentation_class: WorkItemsActivityAggregatedMetric
+instrumentation_class: AggregatedMetric
data_source: redis_hll
time_frame: 7d
options:
diff --git a/config/metrics/counts_7d/20220222215852_xmau_project_management.yml b/config/metrics/counts_7d/20220222215852_xmau_project_management.yml
index 3064b209b7a..2d35f4bf65f 100644
--- a/config/metrics/counts_7d/20220222215852_xmau_project_management.yml
+++ b/config/metrics/counts_7d/20220222215852_xmau_project_management.yml
@@ -9,7 +9,7 @@ status: active
milestone: '14.9'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81336
time_frame: 7d
-instrumentation_class: WorkItemsActivityAggregatedMetric
+instrumentation_class: AggregatedMetric
data_source: redis_hll
options:
aggregate:
diff --git a/config/metrics/counts_7d/20220222215855_users_work_items.yml b/config/metrics/counts_7d/20220222215855_users_work_items.yml
index 9ddca3845cd..6ff0ae942d4 100644
--- a/config/metrics/counts_7d/20220222215855_users_work_items.yml
+++ b/config/metrics/counts_7d/20220222215855_users_work_items.yml
@@ -9,7 +9,7 @@ status: active
milestone: '14.9'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81336
time_frame: 7d
-instrumentation_class: WorkItemsActivityAggregatedMetric
+instrumentation_class: AggregatedMetric
data_source: redis_hll
options:
aggregate:
diff --git a/data/deprecations/16-2-graphql-board-list-totalweight.yml b/data/deprecations/16-2-graphql-board-list-totalweight.yml
new file mode 100644
index 00000000000..0362c30246a
--- /dev/null
+++ b/data/deprecations/16-2-graphql-board-list-totalweight.yml
@@ -0,0 +1,11 @@
+- title: "GraphQL field `totalWeight` is deprecated"
+ announcement_milestone: "16.3"
+ removal_milestone: "17.0"
+ breaking_change: true
+ reporter: tmike
+ stage: Plan
+ issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/416219
+ body: | # (required) Do not modify this line, instead modify the lines below.
+ You can use GraphQL to query the total weight of issues in an issue board. However, the `totalWeight` field is limited to the maximum size 2147483647. As a result, `totalWeight` is deprecated and will be removed in GitLab 17.0.
+
+ Use `totalIssueWeight` instead, introduced in GitLab 16.2.
diff --git a/db/migrate/20230717055659_initialize_conversion_of_ci_pipelines_auto_canceled_by_id.rb b/db/migrate/20230717055659_initialize_conversion_of_ci_pipelines_auto_canceled_by_id.rb
new file mode 100644
index 00000000000..bf0943be0d1
--- /dev/null
+++ b/db/migrate/20230717055659_initialize_conversion_of_ci_pipelines_auto_canceled_by_id.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class InitializeConversionOfCiPipelinesAutoCanceledById < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ TABLE = :ci_pipelines
+ COLUMNS = %i[auto_canceled_by_id]
+
+ def up
+ initialize_conversion_of_integer_to_bigint(TABLE, COLUMNS)
+ end
+
+ def down
+ revert_initialize_conversion_of_integer_to_bigint(TABLE, COLUMNS)
+ end
+end
diff --git a/db/post_migrate/20230717055730_backfill_ci_pipelines_auto_canceled_by_id_conversion.rb b/db/post_migrate/20230717055730_backfill_ci_pipelines_auto_canceled_by_id_conversion.rb
new file mode 100644
index 00000000000..58b91c44b79
--- /dev/null
+++ b/db/post_migrate/20230717055730_backfill_ci_pipelines_auto_canceled_by_id_conversion.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class BackfillCiPipelinesAutoCanceledByIdConversion < Gitlab::Database::Migration[2.1]
+ restrict_gitlab_migration gitlab_schema: :gitlab_ci
+
+ TABLE = :ci_pipelines
+ COLUMNS = %i[auto_canceled_by_id]
+ SUB_BATCH_SIZE = 250
+
+ def up
+ backfill_conversion_of_integer_to_bigint(TABLE, COLUMNS, sub_batch_size: SUB_BATCH_SIZE)
+ end
+
+ def down
+ revert_backfill_conversion_of_integer_to_bigint(TABLE, COLUMNS)
+ end
+end
diff --git a/db/post_migrate/20230721181046_drop_index_issues_on_project_id_and_created_at_issue_type_incident.rb b/db/post_migrate/20230721181046_drop_index_issues_on_project_id_and_created_at_issue_type_incident.rb
new file mode 100644
index 00000000000..3e62f6affad
--- /dev/null
+++ b/db/post_migrate/20230721181046_drop_index_issues_on_project_id_and_created_at_issue_type_incident.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class DropIndexIssuesOnProjectIdAndCreatedAtIssueTypeIncident < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_issues_on_project_id_and_created_at_issue_type_incident'
+
+ def up
+ remove_concurrent_index_by_name :issues, name: INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :issues, [:project_id, :created_at], where: 'issue_type = 1', name: INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20230721194757_drop_index_issues_on_incident_issue_type.rb b/db/post_migrate/20230721194757_drop_index_issues_on_incident_issue_type.rb
new file mode 100644
index 00000000000..ad9b0da28e2
--- /dev/null
+++ b/db/post_migrate/20230721194757_drop_index_issues_on_incident_issue_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class DropIndexIssuesOnIncidentIssueType < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_issues_on_incident_issue_type'
+
+ def up
+ remove_concurrent_index_by_name :issues, name: INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :issues, :issue_type, where: 'issue_type = 1', name: INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20230721200323_drop_index_on_issues_closed_incidents_by_project_id_and_closed_at.rb b/db/post_migrate/20230721200323_drop_index_on_issues_closed_incidents_by_project_id_and_closed_at.rb
new file mode 100644
index 00000000000..243c9f458d9
--- /dev/null
+++ b/db/post_migrate/20230721200323_drop_index_on_issues_closed_incidents_by_project_id_and_closed_at.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class DropIndexOnIssuesClosedIncidentsByProjectIdAndClosedAt < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_on_issues_closed_incidents_by_project_id_and_closed_at'
+
+ def up
+ remove_concurrent_index_by_name :issues, name: INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :issues, [:project_id, :closed_at], where: 'issue_type = 1 AND state_id = 2', name: INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20230721200810_drop_index_on_issues_health_status_asc_order.rb b/db/post_migrate/20230721200810_drop_index_on_issues_health_status_asc_order.rb
new file mode 100644
index 00000000000..a3cc4ff7107
--- /dev/null
+++ b/db/post_migrate/20230721200810_drop_index_on_issues_health_status_asc_order.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class DropIndexOnIssuesHealthStatusAscOrder < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_on_issues_health_status_asc_order'
+
+ def up
+ remove_concurrent_index_by_name :issues, name: INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :issues,
+ [:project_id, :health_status, :id, :state_id, :issue_type],
+ order: { health_status: 'ASC NULLS LAST', id: :desc },
+ name: INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20230721200849_drop_index_on_issues_health_status_desc_order.rb b/db/post_migrate/20230721200849_drop_index_on_issues_health_status_desc_order.rb
new file mode 100644
index 00000000000..a120268b800
--- /dev/null
+++ b/db/post_migrate/20230721200849_drop_index_on_issues_health_status_desc_order.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class DropIndexOnIssuesHealthStatusDescOrder < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_on_issues_health_status_desc_order'
+
+ def up
+ remove_concurrent_index_by_name :issues, name: INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :issues,
+ [:project_id, :health_status, :id, :state_id, :issue_type],
+ order: { health_status: 'DESC NULLS LAST', id: :desc },
+ name: INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20230717055659 b/db/schema_migrations/20230717055659
new file mode 100644
index 00000000000..d88c4204c90
--- /dev/null
+++ b/db/schema_migrations/20230717055659
@@ -0,0 +1 @@
+e54d239394b07f13e0757ad02020d24ad3b89e2167272a3e831577980ab4fcd1 \ No newline at end of file
diff --git a/db/schema_migrations/20230717055730 b/db/schema_migrations/20230717055730
new file mode 100644
index 00000000000..888ab9a3e6f
--- /dev/null
+++ b/db/schema_migrations/20230717055730
@@ -0,0 +1 @@
+1164a2a92c360d426222edb7674b362975ab26cafbc38cb5524540bd2436ba8c \ No newline at end of file
diff --git a/db/schema_migrations/20230721181046 b/db/schema_migrations/20230721181046
new file mode 100644
index 00000000000..106e2313a65
--- /dev/null
+++ b/db/schema_migrations/20230721181046
@@ -0,0 +1 @@
+e48f62e077236469147441f9f2fa3303191b8ec43228bd9fe976ce4e4c2b7e7b \ No newline at end of file
diff --git a/db/schema_migrations/20230721194757 b/db/schema_migrations/20230721194757
new file mode 100644
index 00000000000..2757de65eb5
--- /dev/null
+++ b/db/schema_migrations/20230721194757
@@ -0,0 +1 @@
+9c8da8fe323f38e5e05bd2a09966f3f6c17f3559d8742377c2bbffa35f0c9292 \ No newline at end of file
diff --git a/db/schema_migrations/20230721200323 b/db/schema_migrations/20230721200323
new file mode 100644
index 00000000000..31cefc4a365
--- /dev/null
+++ b/db/schema_migrations/20230721200323
@@ -0,0 +1 @@
+ef6014628f15abb46cd3810176889598b2ae53e9d540e7bdd03f008948604730 \ No newline at end of file
diff --git a/db/schema_migrations/20230721200810 b/db/schema_migrations/20230721200810
new file mode 100644
index 00000000000..24d48f09001
--- /dev/null
+++ b/db/schema_migrations/20230721200810
@@ -0,0 +1 @@
+d7f1a2bf48c9197f153098052d8c8b2b161f07401203a027e8be8ecfa6e4616e \ No newline at end of file
diff --git a/db/schema_migrations/20230721200849 b/db/schema_migrations/20230721200849
new file mode 100644
index 00000000000..e946084ab65
--- /dev/null
+++ b/db/schema_migrations/20230721200849
@@ -0,0 +1 @@
+b7136270895edfff2212d0a8af84cbcfda57de81abcdb8aee29ba7bb63ff749c \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index fadea9e1403..fc0b145ad38 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -267,6 +267,15 @@ BEGIN
END;
$$;
+CREATE FUNCTION trigger_1bd97da9c1a4() RETURNS trigger
+ LANGUAGE plpgsql
+ AS $$
+BEGIN
+ NEW."auto_canceled_by_id_convert_to_bigint" := NEW."auto_canceled_by_id";
+ RETURN NEW;
+END;
+$$;
+
CREATE FUNCTION trigger_239c8032a8d6() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -13778,6 +13787,7 @@ CREATE TABLE ci_pipelines (
locked smallint DEFAULT 1 NOT NULL,
partition_id bigint NOT NULL,
id_convert_to_bigint bigint DEFAULT 0 NOT NULL,
+ auto_canceled_by_id_convert_to_bigint bigint,
CONSTRAINT check_d7e99a025e CHECK ((lock_version IS NOT NULL))
);
@@ -31692,8 +31702,6 @@ CREATE INDEX index_issues_on_duplicated_to_id ON issues USING btree (duplicated_
CREATE INDEX index_issues_on_id_and_weight ON issues USING btree (id, weight);
-CREATE INDEX index_issues_on_incident_issue_type ON issues USING btree (issue_type) WHERE (issue_type = 1);
-
CREATE INDEX index_issues_on_last_edited_by_id ON issues USING btree (last_edited_by_id);
CREATE INDEX index_issues_on_milestone_id ON issues USING btree (milestone_id);
@@ -31706,8 +31714,6 @@ CREATE INDEX index_issues_on_project_health_status_asc_work_item_type ON issues
CREATE INDEX index_issues_on_project_health_status_desc_work_item_type ON issues USING btree (project_id, health_status DESC NULLS LAST, id DESC, state_id, work_item_type_id);
-CREATE INDEX index_issues_on_project_id_and_created_at_issue_type_incident ON issues USING btree (project_id, created_at) WHERE (issue_type = 1);
-
CREATE UNIQUE INDEX index_issues_on_project_id_and_external_key ON issues USING btree (project_id, external_key) WHERE (external_key IS NOT NULL);
CREATE UNIQUE INDEX index_issues_on_project_id_and_iid ON issues USING btree (project_id, iid);
@@ -32246,12 +32252,6 @@ CREATE UNIQUE INDEX index_on_instance_statistics_recorded_at_and_identifier ON a
CREATE INDEX index_on_issue_assignment_events_issue_id_action_created_at_id ON issue_assignment_events USING btree (issue_id, action, created_at, id);
-CREATE INDEX index_on_issues_closed_incidents_by_project_id_and_closed_at ON issues USING btree (project_id, closed_at) WHERE ((issue_type = 1) AND (state_id = 2));
-
-CREATE INDEX index_on_issues_health_status_asc_order ON issues USING btree (project_id, health_status, id DESC, state_id, issue_type);
-
-CREATE INDEX index_on_issues_health_status_desc_order ON issues USING btree (project_id, health_status DESC NULLS LAST, id DESC, state_id, issue_type);
-
CREATE INDEX index_on_label_links_all_columns ON label_links USING btree (target_id, label_id, target_type);
CREATE INDEX index_on_merge_request_reviewers_user_id_and_state ON merge_request_reviewers USING btree (user_id, state) WHERE (state = 2);
@@ -35388,6 +35388,8 @@ CREATE TRIGGER trigger_07bc3c48f407 BEFORE INSERT OR UPDATE ON ci_stages FOR EAC
CREATE TRIGGER trigger_1a857e8db6cd BEFORE INSERT OR UPDATE ON vulnerability_occurrences FOR EACH ROW EXECUTE FUNCTION trigger_1a857e8db6cd();
+CREATE TRIGGER trigger_1bd97da9c1a4 BEFORE INSERT OR UPDATE ON ci_pipelines FOR EACH ROW EXECUTE FUNCTION trigger_1bd97da9c1a4();
+
CREATE TRIGGER trigger_239c8032a8d6 BEFORE INSERT OR UPDATE ON ci_pipeline_chat_data FOR EACH ROW EXECUTE FUNCTION trigger_239c8032a8d6();
CREATE TRIGGER trigger_7f3d66a7d7f5 BEFORE INSERT OR UPDATE ON ci_pipeline_variables FOR EACH ROW EXECUTE FUNCTION trigger_7f3d66a7d7f5();
diff --git a/doc/administration/snippets/index.md b/doc/administration/snippets/index.md
index 9b485140070..6f165a8b0b0 100644
--- a/doc/administration/snippets/index.md
+++ b/doc/administration/snippets/index.md
@@ -11,8 +11,6 @@ Adjust the snippets' settings of your GitLab instance.
## Snippets content size limit
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/31133) in GitLab 12.6.
-
You can set a maximum content size limit for snippets. This limit can prevent
abuse of the feature. The default value is **52428800 Bytes** (50 MB).
@@ -39,7 +37,7 @@ The steps to configure this setting through the Rails console are:
1. Start the Rails console:
```shell
- # For Omnibus installations
+ # For Linux package (Omnibus) installations
sudo gitlab-rails console
# For installations from source
@@ -64,13 +62,16 @@ To set the snippets size limit through the Application Settings API (similar to
[updating any other setting](../../api/settings.md#change-application-settings)), use this command:
```shell
-curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/application/settings?snippet_size_limit=52428800"
+curl --request PUT \
+ --header "PRIVATE-TOKEN: <your_access_token>"
+ --url "https://gitlab.example.com/api/v4/application/settings?snippet_size_limit=52428800"
```
You can also use the API to [retrieve the current value](../../api/settings.md#get-current-application-settings).
```shell
-curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/application/settings"
+curl --header "PRIVATE-TOKEN: <your_access_token>" \
+ --url "https://gitlab.example.com/api/v4/application/settings"
```
## Related topics
diff --git a/doc/api/merge_request_approvals.md b/doc/api/merge_request_approvals.md
index bb86ec54837..55581da64cc 100644
--- a/doc/api/merge_request_approvals.md
+++ b/doc/api/merge_request_approvals.md
@@ -315,8 +315,8 @@ Supported attributes:
| `protected_branch_ids` | Array | **{dotted-circle}** No | The IDs of protected branches to scope the rule by. To identify the ID, [use the API](protected_branches.md#list-protected-branches). |
| `report_type` | string | **{dotted-circle}** No | The report type required when the rule type is `report_approver`. The supported report types are `license_scanning` [(Deprecated in GitLab 15.9)](../update/deprecations.md#license-check-and-the-policies-tab-on-the-license-compliance-page) and `code_coverage`. |
| `rule_type` | string | **{dotted-circle}** No | The type of rule. `any_approver` is a pre-configured default rule with `approvals_required` at `0`. Other rules are `regular` and `report_approver`. |
-| `user_ids` | Array | **{dotted-circle}** No | The IDs of users as approvers. |
-| `usernames` | string array | **{dotted-circle}** No | The usernames for this rule. |
+| `user_ids` | Array | **{dotted-circle}** No | The IDs of users as approvers. If you provide both `user_ids` and `usernames`, both lists of users are added. |
+| `usernames` | string array | **{dotted-circle}** No | The usernames of approvers for this rule (same as `user_ids` but requires a list of usernames). If you provide both `user_ids` and `usernames`, both lists of users are added. |
```json
{
@@ -444,8 +444,8 @@ Supported attributes:
| `group_ids` | Array | **{dotted-circle}** No | The IDs of groups as approvers. |
| `protected_branch_ids` | Array | **{dotted-circle}** No | The IDs of protected branches to scope the rule by. To identify the ID, [use the API](protected_branches.md#list-protected-branches). |
| `remove_hidden_groups` | boolean | **{dotted-circle}** No | Whether hidden groups should be removed. |
-| `user_ids` | Array | **{dotted-circle}** No | The IDs of users as approvers. |
-| `usernames` | string array | **{dotted-circle}** No | The usernames for this rule. |
+| `user_ids` | Array | **{dotted-circle}** No | The IDs of users as approvers. If you provide both `user_ids` and `usernames`, both lists of users are added. |
+| `usernames` | string array | **{dotted-circle}** No | The usernames of approvers for this rule (same as `user_ids` but requires a list of usernames). If you provide both `user_ids` and `usernames`, both lists of users are added.|
```json
{
@@ -859,8 +859,8 @@ Supported attributes:
| `name` | string | **{check-circle}** Yes | The name of the approval rule. |
| `approval_project_rule_id` | integer | **{dotted-circle}** No | The ID of a project-level approval rule. |
| `group_ids` | Array | **{dotted-circle}** No | The IDs of groups as approvers. |
-| `user_ids` | Array | **{dotted-circle}** No | The IDs of users as approvers. |
-| `usernames` | string array | **{dotted-circle}** No | The usernames for this rule. |
+| `user_ids` | Array | **{dotted-circle}** No | The IDs of users as approvers. If you provide both `user_ids` and `usernames`, both lists of users are added.
+| `usernames` | string array | **{dotted-circle}** No | The usernames of approvers for this rule (same as `user_ids` but requires a list of usernames). If you provide both `user_ids` and `usernames`, both lists of users are added. |
**Important:** When `approval_project_rule_id` is set, the `name`, `users` and
`groups` of project-level rule are copied. The `approvals_required` specified
@@ -950,8 +950,8 @@ Supported attributes:
| `group_ids` | Array | **{dotted-circle}** No | The IDs of groups as approvers. |
| `name` | string | **{check-circle}** No | The name of the approval rule. |
| `remove_hidden_groups` | boolean | **{dotted-circle}** No | Whether hidden groups should be removed. |
-| `user_ids` | Array | **{dotted-circle}** No | The IDs of users as approvers. |
-| `usernames` | string array | **{dotted-circle}** No | The usernames for this rule. |
+| `user_ids` | Array | **{dotted-circle}** No | The IDs of users as approvers. If you provide both `user_ids` and `usernames`, both lists of users are added. |
+| `usernames` | string array | **{dotted-circle}** No | The usernames of approvers for this rule (same as `user_ids` but requires a list of usernames). If you provide both `user_ids` and `usernames`, both lists of users are added. |
```json
{
diff --git a/doc/raketasks/x509_signatures.md b/doc/raketasks/x509_signatures.md
index 364264ae204..ab35f432fc2 100644
--- a/doc/raketasks/x509_signatures.md
+++ b/doc/raketasks/x509_signatures.md
@@ -6,8 +6,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# X.509 signatures Rake task **(FREE SELF)**
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/122159) in GitLab 12.10.
-
When [signing commits with X.509](../user/project/repository/x509_signed_commits/index.md),
the trust anchor might change and the signatures stored within the database must be updated.
@@ -18,14 +16,18 @@ certificate store.
To update all X.509 signatures, run:
-**Omnibus Installations:**
+::Tabs
+
+:::TabTitle Linux package (Omnibus)
```shell
sudo gitlab-rake gitlab:x509:update_signatures
```
-**Source Installations:**
+:::TabTitle Self-compiled (source)
```shell
sudo -u git -H bundle exec rake gitlab:x509:update_signatures RAILS_ENV=production
```
+
+::EndTabs
diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md
index 2a7b95304f7..89d7f04b549 100644
--- a/doc/update/deprecations.md
+++ b/doc/update/deprecations.md
@@ -354,6 +354,22 @@ Use `containerRegistrySizeIsEstimated` introduced in GitLab 16.2 instead.
<div class="deprecation breaking-change" data-milestone="17.0">
+### GraphQL field `totalWeight` is deprecated
+
+<div class="deprecation-notes">
+- Announced in GitLab <span class="milestone">16.3</span>
+- Removal in GitLab <span class="milestone">17.0</span> ([breaking change](https://docs.gitlab.com/ee/update/terminology.html#breaking-change))
+- To discuss this change or learn more, see the [deprecation issue](https://gitlab.com/gitlab-org/gitlab/-/issues/416219).
+</div>
+
+You can use GraphQL to query the total weight of issues in an issue board. However, the `totalWeight` field is limited to the maximum size 2147483647. As a result, `totalWeight` is deprecated and will be removed in GitLab 17.0.
+
+Use `totalIssueWeight` instead, introduced in GitLab 16.2.
+
+</div>
+
+<div class="deprecation breaking-change" data-milestone="17.0">
+
### GraphQL type, `RunnerMembershipFilter` renamed to `CiRunnerMembershipFilter`
<div class="deprecation-notes">
diff --git a/doc/user/group/value_stream_analytics/index.md b/doc/user/group/value_stream_analytics/index.md
index dba7a507fef..35865bd60f6 100644
--- a/doc/user/group/value_stream_analytics/index.md
+++ b/doc/user/group/value_stream_analytics/index.md
@@ -143,6 +143,9 @@ Each pre-defined stages of value stream analytics is further described in the ta
| Review | The median time taken to review a merge request that has a closing issue pattern, between its creation and until it's merged. |
| Staging | The median time between merging a merge request that has a closing issue pattern until the very first deployment to a [production environment](#how-value-stream-analytics-identifies-the-production-environment). If there isn't a production environment, this is not tracked. |
+NOTE:
+Value stream analytics works on timestamp data and aggregates only the final start and stop events of the stage. For items that move back and forth between stages multiple times, the stage time is calculated solely from the final events' timestamps.
+
For information about how value stream analytics calculates each stage, see the [Value stream analytics development guide](../../../development/value_stream_analytics.md).
#### Example workflow
diff --git a/doc/user/project/merge_requests/changes.md b/doc/user/project/merge_requests/changes.md
index 79599580f3e..9b0ab278cb2 100644
--- a/doc/user/project/merge_requests/changes.md
+++ b/doc/user/project/merge_requests/changes.md
@@ -23,9 +23,9 @@ To view the diff of changes included in a merge request:
1. Go to your merge request.
1. Below the merge request title, select **Changes**.
1. If the merge request changes many files, you can jump directly to a specific file:
- 1. Select **Show file browser** (**{file-tree}**) to display the file tree.
+ 1. Select **Show file browser** (**{file-tree}**) or press <kbd>F</kbd> to display the file tree.
1. Select the file you want to view.
- 1. To hide the file browser, select **Show file browser** again.
+ 1. To hide the file browser, select **Show file browser** or press <kbd>F</kbd> again.
Files with many changes are collapsed to improve performance. GitLab displays the message:
**Some changes are not shown**. To view the changes for that file, select **Expand file**.
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index ff171c24549..fe2fa892058 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -76,6 +76,7 @@ module Gitlab
push_frontend_feature_flag(:remove_monitor_metrics)
push_frontend_feature_flag(:gitlab_duo, current_user)
push_frontend_feature_flag(:custom_emoji)
+ push_frontend_feature_flag(:super_sidebar_flyout_menus, current_user)
end
# Exposes the state of a feature flag to the frontend code.
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index d35df7e7cb9..ed1f134ff70 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -259,6 +259,10 @@ module Gitlab
@sha256_regex ||= /\A[0-9a-f]{64}\z/i.freeze
end
+ def slack_link_regex
+ @slack_link_regex ||= /<(.*[|].*)>/i.freeze
+ end
+
private
def conan_name_regex
diff --git a/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric.rb b/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric.rb
deleted file mode 100644
index d045265495a..00000000000
--- a/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module Usage
- module Metrics
- module Instrumentations
- class WorkItemsActivityAggregatedMetric < AggregatedMetric
- available? { Feature.enabled?(:track_work_items_activity) }
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb b/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb
index 9de575d8567..b99c9ebb24f 100644
--- a/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb
+++ b/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb
@@ -33,7 +33,7 @@ module Gitlab
private
def track_unique_action(action, author)
- return unless author && Feature.enabled?(:track_work_items_activity)
+ return unless author
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(action, values: author.id)
end
diff --git a/lib/slack_markdown_sanitizer.rb b/lib/slack_markdown_sanitizer.rb
index df3bec1a3c8..f26d9aeb688 100644
--- a/lib/slack_markdown_sanitizer.rb
+++ b/lib/slack_markdown_sanitizer.rb
@@ -8,4 +8,8 @@ module SlackMarkdownSanitizer
def self.sanitize(string)
string&.delete(UNSAFE_MARKUP_CHARACTERS)
end
+
+ def self.sanitize_slack_link(string)
+ string.gsub(Gitlab::Regex.slack_link_regex) { |m| m.gsub("<", "&lt;").gsub(">", "&gt;") }
+ end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a54e1e1345e..d2b4921c0d2 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2207,6 +2207,9 @@ msgstr ""
msgid "AbuseReportEvent|Successfully scheduled the user for deletion and closed the report"
msgstr ""
+msgid "AbuseReports|%{reportedUser} reported for %{category} by %{count} users"
+msgstr ""
+
msgid "AbuseReports|%{reportedUser} reported for %{category} by %{reporter}"
msgstr ""
@@ -2867,6 +2870,9 @@ msgstr ""
msgid "Add new directory"
msgstr ""
+msgid "Add new emoji"
+msgstr ""
+
msgid "Add new webhook"
msgstr ""
@@ -13759,6 +13765,12 @@ msgstr ""
msgid "Custom analyzers: language support"
msgstr ""
+msgid "Custom emoji"
+msgstr ""
+
+msgid "Custom emoji will be available to use in every project in group."
+msgstr ""
+
msgid "Custom hostname (for private commit emails)"
msgstr ""
@@ -18733,9 +18745,6 @@ msgstr ""
msgid "Expires %{preposition} %{expires_at}"
msgstr ""
-msgid "Expires:"
-msgstr ""
-
msgid "Explain why you're reporting the user."
msgstr ""
@@ -22811,7 +22820,7 @@ msgstr ""
msgid "Hide details"
msgstr ""
-msgid "Hide file browser"
+msgid "Hide file browser (or press F)"
msgstr ""
msgid "Hide file contents"
@@ -26744,6 +26753,9 @@ msgstr ""
msgid "Key (PEM)"
msgstr ""
+msgid "Key details"
+msgstr ""
+
msgid "Key result"
msgstr ""
@@ -27048,9 +27060,6 @@ msgstr ""
msgid "Last used %{last_used_at} ago"
msgstr ""
-msgid "Last used on:"
-msgstr ""
-
msgid "Last week"
msgstr ""
@@ -31653,6 +31662,9 @@ msgstr ""
msgid "Number of LOCs per commit"
msgstr ""
+msgid "Number of Reports"
+msgstr ""
+
msgid "Number of commits"
msgstr ""
@@ -35418,6 +35430,12 @@ msgstr ""
msgid "Profiles|Control emails linked to your account"
msgstr ""
+msgid "Profiles|Copy SSH key"
+msgstr ""
+
+msgid "Profiles|Created"
+msgstr ""
+
msgid "Profiles|Created%{time_ago}"
msgstr ""
@@ -35487,9 +35505,15 @@ msgstr ""
msgid "Profiles|Expiration date"
msgstr ""
+msgid "Profiles|Expired"
+msgstr ""
+
msgid "Profiles|Expired:"
msgstr ""
+msgid "Profiles|Expires"
+msgstr ""
+
msgid "Profiles|Expires:"
msgstr ""
@@ -35532,6 +35556,9 @@ msgstr ""
msgid "Profiles|Key titles are publicly visible."
msgstr ""
+msgid "Profiles|Last used"
+msgstr ""
+
msgid "Profiles|Last used:"
msgstr ""
@@ -35595,6 +35622,9 @@ msgstr ""
msgid "Profiles|Resend confirmation email"
msgstr ""
+msgid "Profiles|SSH Key: %{title}"
+msgstr ""
+
msgid "Profiles|Select a service to sign in with."
msgstr ""
@@ -43176,7 +43206,7 @@ msgstr ""
msgid "Show failed jobs (%{count})"
msgstr ""
-msgid "Show file browser"
+msgid "Show file browser (or press F)"
msgstr ""
msgid "Show file contents"
@@ -48509,6 +48539,9 @@ msgstr ""
msgid "Toggle emoji award"
msgstr ""
+msgid "Toggle file browser"
+msgstr ""
+
msgid "Toggle focus mode"
msgstr ""
diff --git a/package.json b/package.json
index dea31836b37..cfcf21c2613 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"@babel/preset-env": "^7.18.2",
"@cubejs-client/core": "^0.33.12",
"@cubejs-client/vue": "^0.33.12",
+ "@floating-ui/dom": "^1.2.9",
"@gitlab/at.js": "1.5.7",
"@gitlab/cluster-client": "^1.2.0",
"@gitlab/favicon-overlay": "2.0.0",
diff --git a/qa/allure/categories.json b/qa/allure/categories.json
new file mode 100644
index 00000000000..828dc02bddc
--- /dev/null
+++ b/qa/allure/categories.json
@@ -0,0 +1,57 @@
+[
+ {
+ "name": "Fabrication 404 failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "^Fabrication of \\S+ .* failed \\\\(404\\\\).*"
+ },
+ {
+ "name": "Fabrication 401 failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "^Fabrication of \\S+ .* failed \\\\(401\\\\)*"
+ },
+ {
+ "name": "Fabrication failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "^Fabrication of \\S+ .* failed \\\\(\\d+\\\\).*"
+ },
+ {
+ "name": "Element not found",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "^(Unable to find css|\\S+ did not appear on).*"
+ },
+ {
+ "name": "Runner did not start failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "^Wait for runner '\\S+' to register in (group|project) '\\S+' failed.*"
+ },
+ {
+ "name": "Click intercepted failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": ".*element click intercepted.*"
+ },
+ {
+ "name": "Page did not fully load failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "^Page did not fully load.*"
+ },
+ {
+ "name": "Waiter failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "^Wait failed after \\d+ seconds.*"
+ },
+ {
+ "name": "Timeout failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "^Timed out reading data from server.*"
+ },
+ {
+ "name": "Ambiguous match failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "Ambiguous match, found \\d+ elements.*"
+ },
+ {
+ "name": "Page validation failure",
+ "matchedStatuses": ["broken"],
+ "messageRegex": "\\S+ did not appear on \\S+ as expected.*"
+ }
+]
diff --git a/qa/qa/runtime/allure_report.rb b/qa/qa/runtime/allure_report.rb
index e726f7a316f..b75163deb67 100644
--- a/qa/qa/runtime/allure_report.rb
+++ b/qa/qa/runtime/allure_report.rb
@@ -35,6 +35,9 @@ module QA
config.issue_tag = :issue
config.link_issue_pattern = '{}'
+ # custom grouping of failures, https://docs.qameta.io/allure-report/#_categories_2
+ config.categories = File.new(File.join(Runtime::Path.qa_root, "allure", "categories.json"))
+
if Env.running_in_ci?
config.environment_properties = environment_info
# Set custom environment name to separate same specs executed in different jobs
diff --git a/qa/qa/specs/features/browser_ui/5_package/container_registry/saas/container_registry_spec.rb b/qa/qa/specs/features/browser_ui/5_package/container_registry/saas/container_registry_spec.rb
index 4b2d9f96cd2..afffcdbf0b7 100644
--- a/qa/qa/specs/features/browser_ui/5_package/container_registry/saas/container_registry_spec.rb
+++ b/qa/qa/specs/features/browser_ui/5_package/container_registry/saas/container_registry_spec.rb
@@ -11,43 +11,45 @@ module QA
end
end
- let(:registry_repository) do
- Resource::RegistryRepository.fabricate! do |repository|
- repository.name = project.path_with_namespace.to_s
- repository.project = project
- end
- end
-
let!(:gitlab_ci_yaml) do
<<~YAML
- build:
- image: docker:24.0.1
- stage: build
- services:
- - docker:24.0.1-dind
- variables:
- IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
- DOCKER_HOST: tcp://docker:2376
- DOCKER_TLS_CERTDIR: "/certs"
- DOCKER_TLS_VERIFY: 1
- DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
- before_script:
- - |
- echo "Waiting for docker to start..."
- for i in $(seq 1 30)
- do
- docker info && break
- sleep 1s
- done
- script:
- - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- - docker build -t $IMAGE_TAG .
- - docker push $IMAGE_TAG
- YAML
- end
+ stages:
+ - test
+ - build
- after do
- registry_repository&.remove_via_api!
+ test:
+ image: registry.gitlab.com/gitlab-ci-utils/curl-jq:latest
+ stage: test
+ script:
+ - 'status_code=$(curl --header "Authorization: Bearer $CI_JOB_TOKEN" "https://${CI_SERVER_HOST}/gitlab/v1")'
+ - |
+ if [ "$status_code" -eq 404 ]; then
+ echo "The registry implements this API specification, but it is unavailable because the metadata database is disabled."
+ exit 1
+ fi
+ build:
+ image: docker:24.0.1
+ stage: build
+ services:
+ - docker:24.0.1-dind
+ variables:
+ IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
+ DOCKER_HOST: tcp://docker:2376
+ DOCKER_TLS_CERTDIR: "/certs"
+ DOCKER_TLS_VERIFY: 1
+ DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
+ before_script:
+ - |
+ echo "Waiting for docker to start..."
+ for i in $(seq 1 30); do
+ docker info && break
+ sleep 1s
+ done
+ script:
+ - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
+ - docker build -t $IMAGE_TAG .
+ - docker push $IMAGE_TAG
+ YAML
end
it 'pushes project image to the container registry and deletes tag',
@@ -69,11 +71,20 @@ module QA
Flow::Pipeline.visit_latest_pipeline
Page::Project::Pipeline::Show.perform do |pipeline|
+ pipeline.click_job('test')
+ end
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_successful(timeout: 200)
+
+ job.click_element(:pipeline_path)
+ end
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
pipeline.click_job('build')
end
Page::Project::Job::Show.perform do |job|
- expect(job).to be_successful(timeout: 800)
+ expect(job).to be_successful(timeout: 500)
end
Page::Project::Menu.perform(&:go_to_container_registry)
diff --git a/qa/qa/specs/features/browser_ui/5_package/container_registry/self_managed/container_registry_spec.rb b/qa/qa/specs/features/browser_ui/5_package/container_registry/self_managed/container_registry_spec.rb
index 800b3a01dc6..1e419197d4f 100644
--- a/qa/qa/specs/features/browser_ui/5_package/container_registry/self_managed/container_registry_spec.rb
+++ b/qa/qa/specs/features/browser_ui/5_package/container_registry/self_managed/container_registry_spec.rb
@@ -43,10 +43,6 @@ module QA
project.visit!
end
- after do
- runner.remove_via_api!
- end
-
context "when tls is disabled" do
where do
{
@@ -200,27 +196,27 @@ module QA
file_path: '.gitlab-ci.yml',
content:
<<~YAML
- build:
- image: docker:23.0.6
- stage: build
- services:
+ build:
+ image: docker:23.0.6
+ stage: build
+ services:
- name: docker:23.0.6-dind
command:
- - /bin/sh
- - -c
- - |
- apk add --no-cache openssl
- true | openssl s_client -showcerts -connect gitlab.test:5050 > /usr/local/share/ca-certificates/gitlab.test.crt
- update-ca-certificates
- dockerd-entrypoint.sh || exit
- variables:
- IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
- script:
- - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD gitlab.test:5050
- - docker build -t $IMAGE_TAG .
- - docker push $IMAGE_TAG
- tags:
- - "runner-for-#{project.name}"
+ - /bin/sh
+ - -c
+ - |
+ apk add --no-cache openssl
+ true | openssl s_client -showcerts -connect gitlab.test:5050 > /usr/local/share/ca-certificates/gitlab.test.crt
+ update-ca-certificates
+ dockerd-entrypoint.sh || exit
+ variables:
+ IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
+ script:
+ - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD gitlab.test:5050
+ - docker build -t $IMAGE_TAG .
+ - docker push $IMAGE_TAG
+ tags:
+ - "runner-for-#{project.name}"
YAML
}
]
@@ -234,7 +230,11 @@ module QA
pipeline.click_job('build')
end
- Support::Retrier.retry_until(max_duration: 800, sleep_interval: 10) do
+ Page::Project::Job::Show.perform do |job|
+ expect(job).to be_successful(timeout: 200)
+ end
+
+ Support::Retrier.retry_until(max_duration: 500, sleep_interval: 10) do
project.pipelines.last[:status] == 'success'
end
diff --git a/spec/features/admin/admin_abuse_reports_spec.rb b/spec/features/admin/admin_abuse_reports_spec.rb
index 9739ea53f81..18bc851558d 100644
--- a/spec/features/admin/admin_abuse_reports_spec.rb
+++ b/spec/features/admin/admin_abuse_reports_spec.rb
@@ -2,27 +2,29 @@
require 'spec_helper'
-RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do
+RSpec.describe "Admin::AbuseReports", :js, feature_category: :insider_threat do
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create(:admin) }
- context 'as an admin' do
- describe 'displayed reports' do
- include FilteredSearchHelpers
+ let_it_be(:open_report) { create(:abuse_report, created_at: 5.days.ago, updated_at: 2.days.ago, category: 'spam', user: user) }
+ let_it_be(:open_report2) { create(:abuse_report, created_at: 4.days.ago, updated_at: 3.days.ago, category: 'phishing') }
+ let_it_be(:closed_report) { create(:abuse_report, :closed, user: user, category: 'spam') }
- let_it_be(:open_report) { create(:abuse_report, created_at: 5.days.ago, updated_at: 2.days.ago) }
- let_it_be(:open_report2) { create(:abuse_report, created_at: 4.days.ago, updated_at: 3.days.ago, category: 'phishing') }
- let_it_be(:closed_report) { create(:abuse_report, :closed) }
+ describe 'as an admin' do
+ before do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ end
- let(:abuse_report_row_selector) { '[data-testid="abuse-report-row"]' }
+ context 'when abuse_reports_list feature flag is enabled' do
+ include FilteredSearchHelpers
before do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
-
visit admin_abuse_reports_path
end
+ let(:abuse_report_row_selector) { '[data-testid="abuse-report-row"]' }
+
it 'only includes open reports by default' do
expect_displayed_reports_count(2)
@@ -68,7 +70,8 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do
end
it 'can be sorted by created_at and updated_at in desc and asc order', :aggregate_failures do
- # created_at desc (default)
+ sort_by 'Created date'
+ # created_at desc
expect(report_rows[0].text).to include(report_text(open_report2))
expect(report_rows[1].text).to include(report_text(open_report))
@@ -78,25 +81,90 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do
expect(report_rows[0].text).to include(report_text(open_report))
expect(report_rows[1].text).to include(report_text(open_report2))
- # updated_at ascending
+ # updated_at asc
sort_by 'Updated date'
expect(report_rows[0].text).to include(report_text(open_report2))
expect(report_rows[1].text).to include(report_text(open_report))
- # updated_at descending
+ # updated_at desc
toggle_sort_direction
expect(report_rows[0].text).to include(report_text(open_report))
expect(report_rows[1].text).to include(report_text(open_report2))
end
+ context 'when multiple reports for the same user are created' do
+ let_it_be(:open_report3) { create(:abuse_report, category: 'spam', user: user) }
+ let_it_be(:closed_report2) { create(:abuse_report, :closed, user: user, category: 'spam') }
+
+ it 'aggregates open reports by user & category', :aggregate_failures do
+ expect_displayed_reports_count(2)
+
+ expect_aggregated_report_shown(open_report, 2)
+ expect_report_shown(open_report2)
+ end
+
+ it 'can sort aggregated reports by number_of_reports in desc order only', :aggregate_failures do
+ sort_by 'Number of Reports'
+
+ expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2))
+ expect(report_rows[1].text).to include(report_text(open_report2))
+
+ toggle_sort_direction
+
+ expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2))
+ expect(report_rows[1].text).to include(report_text(open_report2))
+ end
+
+ it 'can sort aggregated reports by created_at and updated_at in desc and asc order', :aggregate_failures do
+ # number_of_reports desc (default)
+ expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2))
+ expect(report_rows[1].text).to include(report_text(open_report2))
+
+ # created_at desc
+ sort_by 'Created date'
+
+ expect(report_rows[0].text).to include(report_text(open_report2))
+ expect(report_rows[1].text).to include(aggregated_report_text(open_report, 2))
+
+ # created_at asc
+ toggle_sort_direction
+
+ expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2))
+ expect(report_rows[1].text).to include(report_text(open_report2))
+
+ sort_by 'Updated date'
+
+ # updated_at asc
+ expect(report_rows[0].text).to include(report_text(open_report2))
+ expect(report_rows[1].text).to include(aggregated_report_text(open_report, 2))
+
+ # updated_at desc
+ toggle_sort_direction
+
+ expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2))
+ expect(report_rows[1].text).to include(report_text(open_report2))
+ end
+
+ it 'does not aggregate closed reports', :aggregate_failures do
+ filter %w[Status Closed]
+
+ expect_displayed_reports_count(2)
+ expect_report_shown(closed_report, closed_report2)
+ end
+ end
+
def report_rows
page.all(abuse_report_row_selector)
end
def report_text(report)
- "#{report.user.name} reported for #{report.category}"
+ "#{report.user.name} reported for #{report.category} by #{report.reporter.name}"
+ end
+
+ def aggregated_report_text(report, count)
+ "#{report.user.name} reported for #{report.category} by #{count} users"
end
def expect_report_shown(*reports)
@@ -111,6 +179,12 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do
end
end
+ def expect_aggregated_report_shown(*reports, count)
+ reports.each do |r|
+ expect(page).to have_content(aggregated_report_text(r, count))
+ end
+ end
+
def expect_displayed_reports_count(count)
expect(page).to have_css(abuse_report_row_selector, count: count)
end
@@ -138,71 +212,30 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do
before do
stub_feature_flags(abuse_reports_list: false)
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
+ visit admin_abuse_reports_path
end
- describe 'if a user has been reported for abuse' do
- let_it_be(:abuse_report) { create(:abuse_report, user: user) }
-
- describe 'in the abuse report view' do
- before do
- visit admin_abuse_reports_path
- end
-
- it 'presents information about abuse report' do
- expect(page).to have_content('Abuse Reports')
-
- expect(page).to have_content(user.name)
- expect(page).to have_content(abuse_report.reporter.name)
- expect(page).to have_content(abuse_report.message)
- expect(page).to have_link(user.name, href: user_path(user))
- end
-
- it 'present actions items' do
- expect(page).to have_link('Remove user & report')
- expect(page).to have_link('Block user')
- expect(page).to have_link('Remove user')
- end
- end
+ it 'displays all abuse reports', :aggregate_failures do
+ expect_report_shown(open_report)
+ expect_report_actions_shown(open_report)
- describe 'in the profile page of the user' do
- it 'shows a link to view user in the admin area' do
- visit user_path(user)
+ expect_report_shown(open_report2)
+ expect_report_actions_shown(open_report2)
- expect(page).to have_link 'View user in admin area', href: admin_user_path(user)
- end
- end
+ expect_report_shown(closed_report)
+ expect_report_actions_shown(closed_report)
end
- describe 'if an admin has been reported for abuse' do
+ context 'when an admin has been reported for abuse' do
let_it_be(:admin_abuse_report) { create(:abuse_report, user: admin) }
- describe 'in the abuse report view' do
- before do
- visit admin_abuse_reports_path
- end
-
- it 'presents information about abuse report' do
- page.within(:table_row, { "User" => admin.name }) do
- expect(page).to have_content(admin.name)
- expect(page).to have_content(admin_abuse_report.reporter.name)
- expect(page).to have_content(admin_abuse_report.message)
- expect(page).to have_link(admin.name, href: user_path(admin))
- end
- end
-
- it 'does not present actions items' do
- page.within(:table_row, { "User" => admin.name }) do
- expect(page).not_to have_link('Remove user & report')
- expect(page).not_to have_link('Block user')
- expect(page).not_to have_link('Remove user')
- end
- end
+ it 'displays the abuse report without actions' do
+ expect_report_shown(admin_abuse_report)
+ expect_report_actions_not_shown(admin_abuse_report)
end
end
- describe 'if a many users have been reported for abuse' do
+ context 'when multiple users have been reported for abuse' do
let(:report_count) { AbuseReport.default_per_page + 3 }
before do
@@ -211,8 +244,8 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do
end
end
- describe 'in the abuse report view' do
- it 'presents information about abuse report' do
+ context 'in the abuse report view', :aggregate_failures do
+ it 'adds pagination' do
visit admin_abuse_reports_path
expect(page).to have_selector('.pagination')
@@ -221,12 +254,8 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do
end
end
- describe 'filtering by user' do
- let!(:user2) { create(:user) }
- let!(:abuse_report) { create(:abuse_report, user: user) }
- let!(:abuse_report_2) { create(:abuse_report, user: user2) }
-
- it 'shows only single user report' do
+ context 'when filtering reports' do
+ it 'can be filtered by reported-user', :aggregate_failures do
visit admin_abuse_reports_path
page.within '.filter-form' do
@@ -234,14 +263,39 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do
wait_for_requests
page.within '.dropdown-menu-user' do
- click_link user2.name
+ click_link user.name
end
wait_for_requests
end
- expect(page).to have_content(user2.name)
- expect(page).not_to have_content(user.name)
+ expect_report_shown(open_report)
+ expect_report_shown(closed_report)
+ end
+ end
+
+ def expect_report_shown(report)
+ page.within(:table_row, { "User" => report.user.name, "Reported by" => report.reporter.name }) do
+ expect(page).to have_content(report.user.name)
+ expect(page).to have_content(report.reporter.name)
+ expect(page).to have_content(report.message)
+ expect(page).to have_link(report.user.name, href: user_path(report.user))
+ end
+ end
+
+ def expect_report_actions_shown(report)
+ page.within(:table_row, { "User" => report.user.name, "Reported by" => report.reporter.name }) do
+ expect(page).to have_link('Remove user & report')
+ expect(page).to have_link('Block user')
+ expect(page).to have_link('Remove user')
+ end
+ end
+
+ def expect_report_actions_not_shown(report)
+ page.within(:table_row, { "User" => report.user.name, "Reported by" => report.reporter.name }) do
+ expect(page).not_to have_link('Remove user & report')
+ expect(page).not_to have_link('Block user')
+ expect(page).not_to have_link('Remove user')
end
end
end
diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb
index ae61f1cf492..b49d16603b2 100644
--- a/spec/features/profiles/keys_spec.rb
+++ b/spec/features/profiles/keys_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe 'Profile > SSH Keys', feature_category: :user_profile do
fill_in('Title', with: attrs[:title])
click_button('Add key')
- expect(page).to have_content("Title: #{attrs[:title]}")
+ expect(page).to have_content(format(s_('Profiles|SSH Key: %{title}'), title: attrs[:title]))
expect(page).to have_content(attrs[:key])
expect(find('[data-testid="breadcrumb-current-link"]')).to have_link(attrs[:title])
end
diff --git a/spec/finders/abuse_reports_finder_spec.rb b/spec/finders/abuse_reports_finder_spec.rb
index ee93d042ca2..0b641d0cb08 100644
--- a/spec/finders/abuse_reports_finder_spec.rb
+++ b/spec/finders/abuse_reports_finder_spec.rb
@@ -2,142 +2,205 @@
require 'spec_helper'
-RSpec.describe AbuseReportsFinder, '#execute' do
- let_it_be(:user1) { create(:user) }
- let_it_be(:user2) { create(:user) }
- let_it_be(:reporter) { create(:user) }
- let_it_be(:abuse_report_1) { create(:abuse_report, id: 20, category: 'spam', user: user1) }
- let_it_be(:abuse_report_2) do
- create(:abuse_report, :closed, id: 30, category: 'phishing', user: user2, reporter: reporter)
- end
+RSpec.describe AbuseReportsFinder, feature_category: :insider_threat do
+ let_it_be(:user_1) { create(:user) }
+ let_it_be(:user_2) { create(:user) }
- let(:params) { {} }
+ let_it_be(:reporter_1) { create(:user) }
+ let_it_be(:reporter_2) { create(:user) }
- subject { described_class.new(params).execute }
+ let_it_be(:abuse_report_1) do
+ create(:abuse_report, :open, category: 'spam', user: user_1, reporter: reporter_1, id: 1)
+ end
- context 'when params is empty' do
- it 'returns all abuse reports' do
- expect(subject).to match_array([abuse_report_1, abuse_report_2])
- end
+ let_it_be(:abuse_report_2) do
+ create(:abuse_report, :closed, category: 'phishing', user: user_2, reporter: reporter_2, id: 2)
end
- context 'when params[:user_id] is present' do
- let(:params) { { user_id: user2 } }
+ let(:params) { {} }
- it 'returns abuse reports for the specified user' do
- expect(subject).to match_array([abuse_report_2])
- end
- end
+ subject(:finder) { described_class.new(params).execute }
- shared_examples 'returns filtered reports' do |filter_field|
- it "returns abuse reports filtered by #{filter_field}_id" do
- expect(subject).to match_array(filtered_reports)
+ describe '#execute' do
+ context 'when params is empty' do
+ it 'returns all abuse reports' do
+ expect(finder).to match_array([abuse_report_1, abuse_report_2])
+ end
end
- context "when no user has username = params[:#{filter_field}]" do
- before do
- allow(User).to receive_message_chain(:by_username, :pick)
- .with(params[filter_field])
- .with(:id)
- .and_return(nil)
+ shared_examples 'returns filtered reports' do |filter_field|
+ it "returns abuse reports filtered by #{filter_field}_id" do
+ expect(finder).to match_array(filtered_reports)
end
- it 'returns all abuse reports' do
- expect(subject).to match_array([abuse_report_1, abuse_report_2])
+ context "when no user has username = params[:#{filter_field}]" do
+ before do
+ allow(User).to receive_message_chain(:by_username, :pick)
+ .with(params[filter_field])
+ .with(:id)
+ .and_return(nil)
+ end
+
+ it 'returns all abuse reports' do
+ expect(finder).to match_array([abuse_report_1, abuse_report_2])
+ end
end
end
- end
- context 'when params[:user] is present' do
- it_behaves_like 'returns filtered reports', :user do
- let(:params) { { user: user1.username } }
- let(:filtered_reports) { [abuse_report_1] }
+ context 'when params[:user] is present' do
+ it_behaves_like 'returns filtered reports', :user do
+ let(:params) { { user: user_1.username } }
+ let(:filtered_reports) { [abuse_report_1] }
+ end
end
- end
- context 'when params[:reporter] is present' do
- it_behaves_like 'returns filtered reports', :reporter do
- let(:params) { { reporter: reporter.username } }
- let(:filtered_reports) { [abuse_report_2] }
+ context 'when params[:reporter] is present' do
+ it_behaves_like 'returns filtered reports', :reporter do
+ let(:params) { { reporter: reporter_1.username } }
+ let(:filtered_reports) { [abuse_report_1] }
+ end
end
- end
- context 'when params[:status] is present' do
- context 'when value is "open"' do
+ context 'when params[:status] = open' do
let(:params) { { status: 'open' } }
it 'returns only open abuse reports' do
- expect(subject).to match_array([abuse_report_1])
+ expect(finder).to match_array([abuse_report_1])
end
end
- context 'when value is "closed"' do
+ context 'when params[:status] = closed' do
let(:params) { { status: 'closed' } }
it 'returns only closed abuse reports' do
- expect(subject).to match_array([abuse_report_2])
+ expect(finder).to match_array([abuse_report_2])
end
end
- context 'when value is not a valid status' do
+ context 'when params[:status] is not a valid status' do
let(:params) { { status: 'partial' } }
it 'defaults to returning open abuse reports' do
- expect(subject).to match_array([abuse_report_1])
+ expect(finder).to match_array([abuse_report_1])
end
end
- context 'when abuse_reports_list feature flag is disabled' do
- before do
- stub_feature_flags(abuse_reports_list: false)
- end
+ context 'when params[:category] is present' do
+ let(:params) { { category: 'phishing' } }
- it 'does not filter by status' do
- expect(subject).to match_array([abuse_report_1, abuse_report_2])
+ it 'returns abuse reports with the specified category' do
+ expect(subject).to match_array([abuse_report_2])
end
end
- end
- context 'when params[:category] is present' do
- let(:params) { { category: 'phishing' } }
+ describe 'aggregating reports' do
+ context 'when multiple open reports exist' do
+ let(:params) { { status: 'open' } }
- it 'returns abuse reports with the specified category' do
- expect(subject).to match_array([abuse_report_2])
- end
- end
+ # same category and user as abuse_report_1 -> will get aggregated
+ let_it_be(:abuse_report_3) do
+ create(:abuse_report, :open, category: abuse_report_1.category, user: abuse_report_1.user, id: 3)
+ end
- describe 'sorting' do
- let(:params) { { sort: 'created_at_asc' } }
+ # different category, but same user as abuse_report_1 -> won't get aggregated
+ let_it_be(:abuse_report_4) do
+ create(:abuse_report, :open, category: 'phishing', user: abuse_report_1.user, id: 4)
+ end
- it 'returns reports sorted by the specified sort attribute' do
- expect(subject).to eq [abuse_report_1, abuse_report_2]
- end
+ it 'aggregates open reports by user and category' do
+ expect(finder).to match_array([abuse_report_1, abuse_report_4])
+ end
+
+ it 'sorts by aggregated_count in descending order and created_at in descending order' do
+ expect(finder).to eq([abuse_report_1, abuse_report_4])
+ end
+
+ it 'returns count with aggregated reports' do
+ expect(finder[0].count).to eq(2)
+ end
+
+ context 'when a different sorting attribute is given' do
+ let(:params) { { status: 'open', sort: 'created_at_desc' } }
- context 'when sort is not specified' do
- let(:params) { {} }
+ it 'returns reports sorted by the specified sort attribute' do
+ expect(subject).to eq([abuse_report_4, abuse_report_1])
+ end
+ end
- it "returns reports sorted by #{described_class::DEFAULT_SORT}" do
- expect(subject).to eq [abuse_report_2, abuse_report_1]
+ context 'when params[:sort] is invalid' do
+ let(:params) { { status: 'open', sort: 'invalid' } }
+
+ it 'sorts reports by aggregated_count in descending order' do
+ expect(finder).to eq([abuse_report_1, abuse_report_4])
+ end
+ end
end
- end
- context 'when sort is not supported' do
- let(:params) { { sort: 'superiority' } }
+ context 'when multiple closed reports exist' do
+ let(:params) { { status: 'closed' } }
+
+ # same user and category as abuse_report_2 -> won't get aggregated
+ let_it_be(:abuse_report_5) do
+ create(:abuse_report, :closed, category: abuse_report_2.category, user: abuse_report_2.user, id: 5)
+ end
+
+ it 'does not aggregate closed reports' do
+ expect(finder).to match_array([abuse_report_2, abuse_report_5])
+ end
+
+ it 'sorts reports by created_at in descending order' do
+ expect(finder).to eq([abuse_report_5, abuse_report_2])
+ end
+
+ context 'when a different sorting attribute is given' do
+ let(:params) { { status: 'closed', sort: 'created_at_asc' } }
- it "returns reports sorted by #{described_class::DEFAULT_SORT}" do
- expect(subject).to eq [abuse_report_2, abuse_report_1]
+ it 'returns reports sorted by the specified sort attribute' do
+ expect(subject).to eq([abuse_report_2, abuse_report_5])
+ end
+ end
+
+ context 'when params[:sort] is invalid' do
+ let(:params) { { status: 'closed', sort: 'invalid' } }
+
+ it 'sorts reports by created_at in descending order' do
+ expect(finder).to eq([abuse_report_5, abuse_report_2])
+ end
+ end
end
end
- context 'when abuse_reports_list feature flag is disabled' do
- let_it_be(:abuse_report_3) { create(:abuse_report, id: 10) }
-
+ context 'when legacy view is enabled' do
before do
stub_feature_flags(abuse_reports_list: false)
end
- it 'returns reports sorted by id in descending order' do
- expect(subject).to eq [abuse_report_2, abuse_report_1, abuse_report_3]
+ context 'when params is empty' do
+ it 'returns all abuse reports' do
+ expect(subject).to match_array([abuse_report_1, abuse_report_2])
+ end
+ end
+
+ context 'when params[:user_id] is present' do
+ let(:params) { { user_id: user_1 } }
+
+ it 'returns abuse reports for the specified user' do
+ expect(subject).to match_array([abuse_report_1])
+ end
+ end
+
+ context 'when sorting' do
+ it 'returns reports sorted by id in descending order' do
+ expect(subject).to match_array([abuse_report_2, abuse_report_1])
+ end
+ end
+
+ context 'when any of the new filters are present such as params[:status]' do
+ let(:params) { { status: 'open' } }
+
+ it 'returns all abuse reports' do
+ expect(subject).to match_array([abuse_report_1, abuse_report_2])
+ end
end
end
end
diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
index fb92cc34ce9..70f77932ccf 100644
--- a/spec/frontend/access_tokens/components/new_access_token_app_spec.js
+++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
@@ -69,6 +69,7 @@ describe('~/access_tokens/components/new_access_token_app', () => {
const InputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility);
expect(InputCopyToggleVisibilityComponent.props('value')).toBe(newToken);
+ expect(InputCopyToggleVisibilityComponent.props('readonly')).toBe(true);
expect(InputCopyToggleVisibilityComponent.props('copyButtonTitle')).toBe(
sprintf(__('Copy %{accessTokenType}'), { accessTokenType }),
);
diff --git a/spec/frontend/access_tokens/components/token_spec.js b/spec/frontend/access_tokens/components/token_spec.js
index f62f7d72e3b..ad92366c3b6 100644
--- a/spec/frontend/access_tokens/components/token_spec.js
+++ b/spec/frontend/access_tokens/components/token_spec.js
@@ -50,6 +50,7 @@ describe('Token', () => {
formInputGroupProps: {
id: defaultPropsData.inputId,
},
+ readonly: true,
value: defaultPropsData.token,
copyButtonTitle: defaultPropsData.copyButtonTitle,
});
diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js
index 03bf510f3ad..8482faccca0 100644
--- a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js
+++ b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js
@@ -94,4 +94,19 @@ describe('AbuseReportRow', () => {
it('renders abuse category', () => {
expect(findAbuseCategory().exists()).toBe(true);
});
+
+ describe('aggregated report', () => {
+ const mockAggregatedAbuseReport = mockAbuseReports[1];
+ const { reportedUser, category, count } = mockAggregatedAbuseReport;
+
+ beforeEach(() => {
+ createComponent({ report: mockAggregatedAbuseReport });
+ });
+
+ it('displays title with number of aggregated reports', () => {
+ expect(findAbuseReportTitle().text()).toMatchInterpolatedText(
+ `${reportedUser.name} reported for ${category} by ${count} users`,
+ );
+ });
+ });
});
diff --git a/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js
index 1f3f2caa995..dda9263d094 100644
--- a/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js
+++ b/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js
@@ -8,8 +8,10 @@ import {
FILTERED_SEARCH_TOKEN_REPORTER,
FILTERED_SEARCH_TOKEN_STATUS,
FILTERED_SEARCH_TOKEN_CATEGORY,
- DEFAULT_SORT,
- SORT_OPTIONS,
+ DEFAULT_SORT_STATUS_OPEN,
+ DEFAULT_SORT_STATUS_CLOSED,
+ SORT_OPTIONS_STATUS_OPEN,
+ SORT_OPTIONS_STATUS_CLOSED,
} from '~/admin/abuse_reports/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
@@ -53,8 +55,8 @@ describe('AbuseReportsFilteredSearchBar', () => {
recentSearchesStorageKey: 'abuse_reports',
searchInputPlaceholder: 'Filter reports',
tokens: [...FILTERED_SEARCH_TOKENS, categoryToken],
- initialSortBy: DEFAULT_SORT,
- sortOptions: SORT_OPTIONS,
+ initialSortBy: DEFAULT_SORT_STATUS_OPEN,
+ sortOptions: SORT_OPTIONS_STATUS_OPEN,
});
});
@@ -88,6 +90,10 @@ describe('AbuseReportsFilteredSearchBar', () => {
expect(findFilteredSearchBar().props('initialFilterValue')).toMatchObject([
{
+ type: FILTERED_SEARCH_TOKEN_STATUS.type,
+ value: { data: 'closed', operator: '=' },
+ },
+ {
type: FILTERED_SEARCH_TOKEN_USER.type,
value: { data: 'mr_abuser', operator: '=' },
},
@@ -95,16 +101,12 @@ describe('AbuseReportsFilteredSearchBar', () => {
type: FILTERED_SEARCH_TOKEN_REPORTER.type,
value: { data: 'ms_nitch', operator: '=' },
},
- {
- type: FILTERED_SEARCH_TOKEN_STATUS.type,
- value: { data: 'closed', operator: '=' },
- },
]);
});
describe('initial sort', () => {
it.each(
- SORT_OPTIONS.flatMap(({ sortDirection: { descending, ascending } }) => [
+ SORT_OPTIONS_STATUS_OPEN.flatMap(({ sortDirection: { descending, ascending } }) => [
descending,
ascending,
]),
@@ -115,16 +117,20 @@ describe('AbuseReportsFilteredSearchBar', () => {
createComponent();
- expect(findFilteredSearchBar().props('initialSortBy')).toEqual(sortBy);
+ if (sortBy) {
+ expect(findFilteredSearchBar().props('initialSortBy')).toEqual(sortBy);
+ } else {
+ expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT_STATUS_OPEN);
+ }
},
);
- it(`uses ${DEFAULT_SORT} as initialSortBy when sort query param is invalid`, () => {
+ it(`uses ${DEFAULT_SORT_STATUS_OPEN} as initialSortBy when sort query param is invalid`, () => {
setWindowLocation(`?sort=unknown`);
createComponent();
- expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT);
+ expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT_STATUS_OPEN);
});
});
@@ -161,26 +167,39 @@ describe('AbuseReportsFilteredSearchBar', () => {
(filterToken) => {
createComponentAndFilter([filterToken]);
const { type, value } = filterToken;
- expect(redirectTo).toHaveBeenCalledWith(`https://localhost/?${type}=${value.data}`); // eslint-disable-line import/no-deprecated
+
+ // eslint-disable-next-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith(
+ `https://localhost/?${type}=${value.data}&sort=${DEFAULT_SORT_STATUS_OPEN}`,
+ );
},
);
it('ignores search query param', () => {
const searchFilterToken = { type: FILTERED_SEARCH_TERM, value: { data: 'ignored' } };
createComponentAndFilter([USER_FILTER_TOKEN, searchFilterToken]);
- expect(redirectTo).toHaveBeenCalledWith('https://localhost/?user=mr_abuser'); // eslint-disable-line import/no-deprecated
+
+ // eslint-disable-next-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith(
+ `https://localhost/?user=mr_abuser&sort=${DEFAULT_SORT_STATUS_OPEN}`,
+ );
});
it('redirects without page query param', () => {
createComponentAndFilter([USER_FILTER_TOKEN], '?page=2');
- expect(redirectTo).toHaveBeenCalledWith('https://localhost/?user=mr_abuser'); // eslint-disable-line import/no-deprecated
+
+ // eslint-disable-next-line import/no-deprecated
+ expect(redirectTo).toHaveBeenCalledWith(
+ `https://localhost/?user=mr_abuser&sort=${DEFAULT_SORT_STATUS_OPEN}`,
+ );
});
it('redirects with existing sort query param', () => {
- createComponentAndFilter([USER_FILTER_TOKEN], `?sort=${DEFAULT_SORT}`);
+ createComponentAndFilter([USER_FILTER_TOKEN], `?sort=${DEFAULT_SORT_STATUS_OPEN}`);
+
// eslint-disable-next-line import/no-deprecated
expect(redirectTo).toHaveBeenCalledWith(
- `https://localhost/?user=mr_abuser&sort=${DEFAULT_SORT}`,
+ `https://localhost/?user=mr_abuser&sort=${DEFAULT_SORT_STATUS_OPEN}`,
);
});
});
@@ -222,4 +241,42 @@ describe('AbuseReportsFilteredSearchBar', () => {
);
});
});
+
+ describe('sortOptions', () => {
+ describe('when status is closed', () => {
+ beforeEach(() => {
+ setWindowLocation('?status=closed');
+
+ createComponent();
+ });
+
+ it('only shows created_at & updated_at as sorting options', () => {
+ expect(findFilteredSearchBar().props('sortOptions')).toMatchObject(
+ SORT_OPTIONS_STATUS_CLOSED,
+ );
+ });
+
+ it('initially sorts by created_at_desc', () => {
+ expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT_STATUS_CLOSED);
+ });
+ });
+
+ describe('when status is open', () => {
+ beforeEach(() => {
+ setWindowLocation('?status=open');
+
+ createComponent();
+ });
+
+ it('shows number of reports as an additional sorting option', () => {
+ expect(findFilteredSearchBar().props('sortOptions')).toMatchObject(
+ SORT_OPTIONS_STATUS_OPEN,
+ );
+ });
+
+ it('initially sorts by number_of_reports_desc', () => {
+ expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT_STATUS_OPEN);
+ });
+ });
+ });
});
diff --git a/spec/frontend/admin/abuse_reports/mock_data.js b/spec/frontend/admin/abuse_reports/mock_data.js
index 1ea6ea7d131..33a28a21cca 100644
--- a/spec/frontend/admin/abuse_reports/mock_data.js
+++ b/spec/frontend/admin/abuse_reports/mock_data.js
@@ -6,6 +6,7 @@ export const mockAbuseReports = [
reporter: { name: 'Ms. Admin' },
reportedUser: { name: 'Mr. Abuser' },
reportPath: '/admin/abuse_reports/1',
+ count: 1,
},
{
category: 'phishing',
@@ -14,5 +15,6 @@ export const mockAbuseReports = [
reporter: { name: 'Ms. Reporter' },
reportedUser: { name: 'Mr. Phisher' },
reportPath: '/admin/abuse_reports/2',
+ count: 2,
},
];
diff --git a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js
index 0d9196b88ed..aef06a74fdd 100644
--- a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js
+++ b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js
@@ -6,12 +6,10 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RecoveryCodes, {
i18n,
} from '~/authentication/two_factor_auth/components/recovery_codes.vue';
-import {
- RECOVERY_CODE_DOWNLOAD_FILENAME,
- COPY_KEYBOARD_SHORTCUT,
-} from '~/authentication/two_factor_auth/constants';
+import { RECOVERY_CODE_DOWNLOAD_FILENAME } from '~/authentication/two_factor_auth/constants';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap';
import { codes, codesFormattedString, codesDownloadHref, profileAccountPath } from '../mock_data';
describe('RecoveryCodes', () => {
@@ -42,7 +40,7 @@ describe('RecoveryCodes', () => {
const findPrintButton = () => findButtonByText('Print codes');
const findProceedButton = () => findButtonByText('Proceed');
const manuallyCopyRecoveryCodes = () =>
- wrapper.vm.$options.mousetrap.trigger(COPY_KEYBOARD_SHORTCUT);
+ wrapper.vm.$options.mousetrap.trigger(MOUSETRAP_COPY_KEYBOARD_SHORTCUT);
beforeEach(() => {
jest.spyOn(Tracking, 'event');
diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js
index e4373d1c198..3fb845b186a 100644
--- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js
+++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js
@@ -168,9 +168,8 @@ describe('RegistrationDropdown', () => {
expect(findTokenDropdownItem().exists()).toBe(true);
});
- it('Displays masked value by default', () => {
+ it('Displays masked value as password input by default', () => {
const mockToken = '0123456789';
- const maskToken = '**********';
createComponent(
{
@@ -179,7 +178,7 @@ describe('RegistrationDropdown', () => {
mountExtended,
);
- expect(findRegistrationTokenInput().element.value).toBe(maskToken);
+ expect(findRegistrationTokenInput().element.type).toBe('password');
});
});
diff --git a/spec/frontend/ci/runner/components/registration/registration_token_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_spec.js
index fd3896d5500..eccfe43b47f 100644
--- a/spec/frontend/ci/runner/components/registration/registration_token_spec.js
+++ b/spec/frontend/ci/runner/components/registration/registration_token_spec.js
@@ -38,10 +38,15 @@ describe('RegistrationToken', () => {
);
});
+ it('Renders readonly input', () => {
+ createComponent();
+
+ expect(findInputCopyToggleVisibility().props('readonly')).toBe(true);
+ });
+
// Component integration test to ensure secure masking
- it('Displays masked value by default', () => {
+ it('Displays masked value as password input by default', () => {
const mockToken = '0123456789';
- const maskToken = '**********';
createComponent({
props: {
@@ -50,7 +55,7 @@ describe('RegistrationToken', () => {
mountFn: mountExtended,
});
- expect(wrapper.find('input').element.value).toBe(maskToken);
+ expect(wrapper.find('input').element.type).toBe('password');
});
describe('When the copy to clipboard button is clicked', () => {
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index fb5cf4dfd0a..2ad04a7c371 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -11,7 +11,7 @@ import CompareVersions from '~/diffs/components/compare_versions.vue';
import DiffFile from '~/diffs/components/diff_file.vue';
import NoChanges from '~/diffs/components/no_changes.vue';
import findingsDrawer from '~/diffs/components/shared/findings_drawer.vue';
-import TreeList from '~/diffs/components/tree_list.vue';
+import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue';
import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue';
import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
@@ -252,34 +252,6 @@ describe('diffs/components/app', () => {
});
});
- describe('resizable', () => {
- afterEach(() => {
- localStorage.removeItem('mr_tree_list_width');
- });
-
- it('sets initial width when no localStorage has been set', () => {
- createComponent();
-
- expect(wrapper.vm.treeWidth).toEqual(320);
- });
-
- it('sets initial width to localStorage size', () => {
- localStorage.setItem('mr_tree_list_width', '200');
-
- createComponent();
-
- expect(wrapper.vm.treeWidth).toEqual(200);
- });
-
- it('sets width of tree list', () => {
- createComponent({}, ({ state }) => {
- state.diffs.treeEntries = { 111: { type: 'blob', fileHash: '111', path: '111.js' } };
- });
-
- expect(wrapper.find('.js-diff-tree-list').element.style.width).toEqual('320px');
- });
- });
-
it('marks current diff file based on currently highlighted row', async () => {
window.location.hash = 'ABC_123';
@@ -596,18 +568,21 @@ describe('diffs/components/app', () => {
);
});
- it("doesn't render tree list when no changes exist", () => {
+ it('should always render diffs file tree', () => {
createComponent();
-
- expect(wrapper.findComponent(TreeList).exists()).toBe(false);
+ expect(wrapper.findComponent(DiffsFileTree).exists()).toBe(true);
});
- it('should render tree list', () => {
+ it('should pass renderDiffFiles to file tree as true when files are present', () => {
createComponent({}, ({ state }) => {
state.diffs.treeEntries = { 111: { type: 'blob', fileHash: '111', path: '111.js' } };
});
+ expect(wrapper.findComponent(DiffsFileTree).props('renderDiffFiles')).toBe(true);
+ });
- expect(wrapper.findComponent(TreeList).exists()).toBe(true);
+ it('should pass renderDiffFiles to file tree as false without files', () => {
+ createComponent();
+ expect(wrapper.findComponent(DiffsFileTree).props('renderDiffFiles')).toBe(false);
});
});
diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js
index cbbfd88260b..3601f0cc7b0 100644
--- a/spec/frontend/diffs/components/compare_versions_spec.js
+++ b/spec/frontend/diffs/components/compare_versions_spec.js
@@ -84,7 +84,7 @@ describe('CompareVersions', () => {
const treeListBtn = wrapper.find('.js-toggle-tree-list');
expect(treeListBtn.exists()).toBe(true);
- expect(treeListBtn.attributes('title')).toBe('Hide file browser');
+ expect(treeListBtn.attributes('title')).toBe('Hide file browser (or press F)');
expect(treeListBtn.props('icon')).toBe('file-tree');
});
diff --git a/spec/frontend/diffs/components/diffs_file_tree_spec.js b/spec/frontend/diffs/components/diffs_file_tree_spec.js
new file mode 100644
index 00000000000..a79023a07cb
--- /dev/null
+++ b/spec/frontend/diffs/components/diffs_file_tree_spec.js
@@ -0,0 +1,116 @@
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { Mousetrap } from '~/lib/mousetrap';
+import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue';
+import TreeList from '~/diffs/components/tree_list.vue';
+import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+import { SET_SHOW_TREE_LIST } from '~/diffs/store/mutation_types';
+import createDiffsStore from '../create_diffs_store';
+
+describe('DiffsFileTree', () => {
+ let wrapper;
+ let store;
+
+ const createComponent = ({ renderDiffFiles = true, showTreeList = true } = {}) => {
+ store = createDiffsStore();
+ store.commit(`diffs/${SET_SHOW_TREE_LIST}`, showTreeList);
+ wrapper = shallowMount(DiffsFileTree, {
+ store,
+ propsData: {
+ renderDiffFiles,
+ },
+ });
+ };
+
+ describe('visibility', () => {
+ describe('when renderDiffFiles and showTreeList are true', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('tree list is visible', () => {
+ expect(wrapper.findComponent(TreeList).exists()).toBe(true);
+ });
+ });
+
+ describe('when renderDiffFiles and showTreeList are false', () => {
+ beforeEach(() => {
+ createComponent({ renderDiffFiles: false, showTreeList: false });
+ });
+
+ it('tree list is hidden', () => {
+ expect(wrapper.findComponent(TreeList).exists()).toBe(false);
+ });
+ });
+ });
+
+ it('emits toggled event', async () => {
+ createComponent();
+ store.commit(`diffs/${SET_SHOW_TREE_LIST}`, false);
+ await nextTick();
+ expect(wrapper.emitted('toggled')).toStrictEqual([[]]);
+ });
+
+ it('toggles when "f" hotkey is pressed', async () => {
+ createComponent();
+ Mousetrap.trigger('f');
+ await nextTick();
+ expect(wrapper.findComponent(TreeList).exists()).toBe(false);
+ });
+
+ describe('size', () => {
+ const checkWidth = (width) => {
+ expect(wrapper.element.style.width).toEqual(`${width}px`);
+ expect(wrapper.findComponent(PanelResizer).props('startSize')).toEqual(width);
+ };
+
+ afterEach(() => {
+ localStorage.removeItem('mr_tree_list_width');
+ });
+
+ describe('when no localStorage record is set', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('sets initial width when no localStorage has been set', () => {
+ checkWidth(320);
+ });
+ });
+
+ it('sets initial width to localStorage size', () => {
+ localStorage.setItem('mr_tree_list_width', '200');
+ createComponent();
+ checkWidth(200);
+ });
+
+ it('sets width of tree list', () => {
+ createComponent({}, ({ state }) => {
+ state.diffs.treeEntries = { 111: { type: 'blob', fileHash: '111', path: '111.js' } };
+ });
+ checkWidth(320);
+ });
+
+ it('updates width', async () => {
+ const WIDTH = 500;
+ createComponent();
+ wrapper.findComponent(PanelResizer).vm.$emit('update:size', WIDTH);
+ await nextTick();
+ checkWidth(WIDTH);
+ });
+
+ it('passes down hideFileStats as true when width is less than 260', async () => {
+ createComponent();
+ wrapper.findComponent(PanelResizer).vm.$emit('update:size', 200);
+ await nextTick();
+ expect(wrapper.findComponent(TreeList).props('hideFileStats')).toBe(true);
+ });
+
+ it('passes down hideFileStats as false when width is bigger than 260', async () => {
+ createComponent();
+ wrapper.findComponent(PanelResizer).vm.$emit('update:size', 300);
+ await nextTick();
+ expect(wrapper.findComponent(TreeList).props('hideFileStats')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/oauth_application/components/oauth_secret_spec.js b/spec/frontend/oauth_application/components/oauth_secret_spec.js
index c38bd066da8..5ad55c1e81b 100644
--- a/spec/frontend/oauth_application/components/oauth_secret_spec.js
+++ b/spec/frontend/oauth_application/components/oauth_secret_spec.js
@@ -47,6 +47,10 @@ describe('OAuthSecret', () => {
it('shows the renew secret button', () => {
expect(findRenewSecretButton().exists()).toBe(true);
});
+
+ it('renders secret in readonly input', () => {
+ expect(findInputCopyToggleVisibility().props('readonly')).toBe(true);
+ });
});
describe('when secret is not provided', () => {
diff --git a/spec/frontend/super_sidebar/components/flyout_menu_spec.js b/spec/frontend/super_sidebar/components/flyout_menu_spec.js
new file mode 100644
index 00000000000..b894d29c875
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/flyout_menu_spec.js
@@ -0,0 +1,25 @@
+import { shallowMount } from '@vue/test-utils';
+import FlyoutMenu from '~/super_sidebar/components/flyout_menu.vue';
+
+jest.mock('@floating-ui/dom');
+
+describe('FlyoutMenu', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(FlyoutMenu, {
+ propsData: {
+ targetId: 'section-1',
+ items: [],
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the component', () => {
+ expect(wrapper.exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/menu_section_spec.js b/spec/frontend/super_sidebar/components/menu_section_spec.js
index 556e07a2e31..dd729d8fd6a 100644
--- a/spec/frontend/super_sidebar/components/menu_section_spec.js
+++ b/spec/frontend/super_sidebar/components/menu_section_spec.js
@@ -2,6 +2,7 @@ import { GlCollapse } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import MenuSection from '~/super_sidebar/components/menu_section.vue';
import NavItem from '~/super_sidebar/components/nav_item.vue';
+import FlyoutMenu from '~/super_sidebar/components/flyout_menu.vue';
import { stubComponent } from 'helpers/stub_component';
describe('MenuSection component', () => {
@@ -9,6 +10,7 @@ describe('MenuSection component', () => {
const findButton = () => wrapper.find('button');
const findCollapse = () => wrapper.getComponent(GlCollapse);
+ const findFlyout = () => wrapper.findComponent(FlyoutMenu);
const findNavItems = () => wrapper.findAllComponents(NavItem);
const createWrapper = (item, otherProps) => {
wrapper = shallowMountExtended(MenuSection, {
@@ -68,6 +70,40 @@ describe('MenuSection component', () => {
});
});
+ describe('flyout behavior', () => {
+ describe('when hasFlyout is false', () => {
+ it('is not rendered', () => {
+ createWrapper({ title: 'Asdf' }, { 'has-flyout': false });
+ expect(findFlyout().exists()).toBe(false);
+ });
+ });
+
+ describe('when hasFlyout is true', () => {
+ it('is rendered', () => {
+ createWrapper({ title: 'Asdf' }, { 'has-flyout': true });
+ expect(findFlyout().exists()).toBe(true);
+ });
+
+ describe('on mouse hover', () => {
+ describe('when section is expanded', () => {
+ it('is not shown', async () => {
+ createWrapper({ title: 'Asdf' }, { 'has-flyout': true, expanded: true });
+ await findButton().trigger('pointerover', { pointerType: 'mouse' });
+ expect(findFlyout().isVisible()).toBe(false);
+ });
+ });
+
+ describe('when section is not expanded', () => {
+ it('is shown', async () => {
+ createWrapper({ title: 'Asdf' }, { 'has-flyout': true, expanded: false });
+ await findButton().trigger('pointerover', { pointerType: 'mouse' });
+ expect(findFlyout().isVisible()).toBe(true);
+ });
+ });
+ });
+ });
+ });
+
describe('`separated` prop', () => {
describe('by default (false)', () => {
it('does not render a separator', () => {
diff --git a/spec/frontend/super_sidebar/components/pinned_section_spec.js b/spec/frontend/super_sidebar/components/pinned_section_spec.js
index fd6e2b7343e..00cc7cf29c9 100644
--- a/spec/frontend/super_sidebar/components/pinned_section_spec.js
+++ b/spec/frontend/super_sidebar/components/pinned_section_spec.js
@@ -2,10 +2,12 @@ import { nextTick } from 'vue';
import Cookies from '~/lib/utils/cookies';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import PinnedSection from '~/super_sidebar/components/pinned_section.vue';
+import MenuSection from '~/super_sidebar/components/menu_section.vue';
import NavItem from '~/super_sidebar/components/nav_item.vue';
import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '~/super_sidebar/constants';
import { setCookie } from '~/lib/utils/common_utils';
+jest.mock('@floating-ui/dom');
jest.mock('~/lib/utils/common_utils', () => ({
getCookie: jest.requireActual('~/lib/utils/common_utils').getCookie,
setCookie: jest.fn(),
@@ -16,10 +18,11 @@ describe('PinnedSection component', () => {
const findToggle = () => wrapper.find('button');
- const createWrapper = () => {
+ const createWrapper = (props = {}) => {
wrapper = mountExtended(PinnedSection, {
propsData: {
items: [{ title: 'Pin 1', href: '/page1' }],
+ ...props,
},
});
};
@@ -72,4 +75,16 @@ describe('PinnedSection component', () => {
});
});
});
+
+ describe('hasFlyout prop', () => {
+ describe.each([true, false])(`when %s`, (hasFlyout) => {
+ beforeEach(() => {
+ createWrapper({ hasFlyout });
+ });
+
+ it(`passes ${hasFlyout} to the section's hasFlyout prop`, () => {
+ expect(wrapper.findComponent(MenuSection).props('hasFlyout')).toBe(hasFlyout);
+ });
+ });
+ });
});
diff --git a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js
index 21e5220edd9..ac94f3f8f82 100644
--- a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js
@@ -1,3 +1,4 @@
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue';
import PinnedSection from '~/super_sidebar/components/pinned_section.vue';
@@ -15,9 +16,13 @@ const menuItems = [
describe('Sidebar Menu', () => {
let wrapper;
+ let flyoutFlag = false;
const createWrapper = (extraProps = {}) => {
wrapper = shallowMountExtended(SidebarMenu, {
+ provide: {
+ glFeatures: { superSidebarFlyoutMenus: flyoutFlag },
+ },
propsData: {
items: sidebarData.current_menu_items,
pinnedItemIds: sidebarData.pinned_items,
@@ -117,6 +122,65 @@ describe('Sidebar Menu', () => {
);
});
});
+
+ describe('flyout menus', () => {
+ describe('when feature is disabled', () => {
+ beforeEach(() => {
+ createWrapper({
+ items: menuItems,
+ });
+ });
+
+ it('does not add flyout menus to sections', () => {
+ expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([
+ false,
+ false,
+ ]);
+ });
+ });
+
+ describe('when feature is enabled', () => {
+ beforeEach(() => {
+ flyoutFlag = true;
+ });
+
+ describe('when screen width is smaller than "md" breakpoint', () => {
+ beforeEach(() => {
+ jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => {
+ return 767;
+ });
+ createWrapper({
+ items: menuItems,
+ });
+ });
+
+ it('does not add flyout menus to sections', () => {
+ expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([
+ false,
+ false,
+ ]);
+ });
+ });
+
+ describe('when screen width is equal or larger than "md" breakpoint', () => {
+ beforeEach(() => {
+ jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => {
+ return 768;
+ });
+ createWrapper({
+ items: menuItems,
+ });
+ });
+
+ it('adds flyout menus to sections', () => {
+ expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([
+ true,
+ true,
+ ]);
+ });
+ });
+ });
+ });
});
describe('Separators', () => {
diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
index 4f1603f93ba..7afa8e9b8dc 100644
--- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
+++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
@@ -1,11 +1,12 @@
+import { nextTick } from 'vue';
import { merge } from 'lodash';
import { GlFormInputGroup } from '@gitlab/ui';
import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap';
describe('InputCopyToggleVisibility', () => {
let wrapper;
@@ -40,6 +41,18 @@ describe('InputCopyToggleVisibility', () => {
return event;
};
+ const triggerCopyShortcut = () => {
+ wrapper.vm.$options.mousetrap.trigger(MOUSETRAP_COPY_KEYBOARD_SHORTCUT);
+ };
+
+ function expectInputToBeMasked() {
+ expect(findFormInput().element.type).toBe('password');
+ }
+
+ function expectInputToBeRevealed() {
+ expect(findFormInput().element.type).toBe('text');
+ expect(findFormInput().element.value).toBe(valueProp);
+ }
const itDoesNotModifyCopyEvent = () => {
it('does not modify copy event', () => {
@@ -61,29 +74,55 @@ describe('InputCopyToggleVisibility', () => {
});
});
- it('displays value as hidden', () => {
- expect(findFormInput().element.value).toBe('********************');
+ it('hides the value with a password input', () => {
+ expectInputToBeMasked();
});
- it('saves actual value to clipboard when manually copied', () => {
- const event = createCopyEvent();
- findFormInput().element.dispatchEvent(event);
+ it('emits `copy` event and sets clipboard when copying token via keyboard shortcut', async () => {
+ const writeTextSpy = jest.spyOn(global.navigator.clipboard, 'writeText');
- expect(event.clipboardData.setData).toHaveBeenCalledWith('text/plain', valueProp);
- expect(event.preventDefault).toHaveBeenCalled();
- });
-
- it('emits `copy` event when manually copied the token', () => {
expect(wrapper.emitted('copy')).toBeUndefined();
- findFormInput().element.dispatchEvent(createCopyEvent());
+ triggerCopyShortcut();
+ await nextTick();
- expect(wrapper.emitted()).toHaveProperty('copy');
- expect(wrapper.emitted('copy')).toHaveLength(1);
expect(wrapper.emitted('copy')[0]).toEqual([]);
+ expect(writeTextSpy).toHaveBeenCalledWith(valueProp);
});
+ describe('copy button', () => {
+ it('renders button with correct props passed', () => {
+ expect(findCopyButton().props()).toMatchObject({
+ text: valueProp,
+ title: 'Copy',
+ });
+ });
+
+ describe('when clicked', () => {
+ beforeEach(async () => {
+ await findCopyButton().trigger('click');
+ });
+
+ it('emits `copy` event', () => {
+ expect(wrapper.emitted()).toHaveProperty('copy');
+ expect(wrapper.emitted('copy')).toHaveLength(1);
+ expect(wrapper.emitted('copy')[0]).toEqual([]);
+ });
+ });
+ });
+ });
+
+ describe('when input is readonly', () => {
describe('visibility toggle button', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ value: valueProp,
+ readonly: true,
+ },
+ });
+ });
+
it('renders a reveal button', () => {
const revealButton = findRevealButton();
@@ -103,7 +142,7 @@ describe('InputCopyToggleVisibility', () => {
});
it('displays value', () => {
- expect(findFormInput().element.value).toBe(valueProp);
+ expectInputToBeRevealed();
});
it('renders a hide button', () => {
@@ -127,78 +166,161 @@ describe('InputCopyToggleVisibility', () => {
});
});
- describe('copy button', () => {
- it('renders button with correct props passed', () => {
- expect(findCopyButton().props()).toMatchObject({
- text: valueProp,
- title: 'Copy',
+ describe('when `initialVisibility` prop is `true`', () => {
+ const label = 'My label';
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ value: valueProp,
+ initialVisibility: true,
+ readonly: true,
+ label,
+ 'label-for': 'my-input',
+ formInputGroupProps: {
+ id: 'my-input',
+ },
+ },
});
});
- describe('when clicked', () => {
- beforeEach(async () => {
- await findCopyButton().trigger('click');
+ it('displays value', () => {
+ expectInputToBeRevealed();
+ });
+
+ itDoesNotModifyCopyEvent();
+
+ describe('when input is clicked', () => {
+ it('selects input value', async () => {
+ const mockSelect = jest.fn();
+ wrapper.vm.$refs.input.$el.select = mockSelect;
+ await findFormInput().trigger('click');
+
+ expect(mockSelect).toHaveBeenCalled();
});
+ });
- it('emits `copy` event', () => {
- expect(wrapper.emitted()).toHaveProperty('copy');
- expect(wrapper.emitted('copy')).toHaveLength(1);
- expect(wrapper.emitted('copy')[0]).toEqual([]);
+ describe('when label is clicked', () => {
+ it('selects input value', async () => {
+ const mockSelect = jest.fn();
+ wrapper.vm.$refs.input.$el.select = mockSelect;
+ await wrapper.find('label').trigger('click');
+
+ expect(mockSelect).toHaveBeenCalled();
});
});
});
});
- describe('when `value` prop is not passed', () => {
- beforeEach(() => {
- createComponent();
- });
+ describe('when input is editable', () => {
+ describe('and no `value` prop is passed', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ value: '',
+ readonly: false,
+ },
+ });
+ });
- it('displays value as hidden with 20 asterisks', () => {
- expect(findFormInput().element.value).toBe('********************');
- });
- });
+ it('displays value', () => {
+ expect(findRevealButton().exists()).toBe(false);
+ expect(findHideButton().exists()).toBe(true);
- describe('when `initialVisibility` prop is `true`', () => {
- const label = 'My label';
+ const input = findFormInput();
+ input.element.value = valueProp;
+ input.trigger('input');
- beforeEach(() => {
- createComponent({
- propsData: {
- value: valueProp,
- initialVisibility: true,
- label,
- 'label-for': 'my-input',
- formInputGroupProps: {
- id: 'my-input',
- },
- },
+ expectInputToBeRevealed();
});
});
- it('displays value', () => {
- expect(findFormInput().element.value).toBe(valueProp);
- });
+ describe('and `value` prop is passed', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ value: valueProp,
+ readonly: false,
+ },
+ });
+ });
- itDoesNotModifyCopyEvent();
+ it('renders a reveal button', () => {
+ const revealButton = findRevealButton();
- describe('when input is clicked', () => {
- it('selects input value', async () => {
- const mockSelect = jest.fn();
- wrapper.vm.$refs.input.$el.select = mockSelect;
- await wrapper.findByLabelText(label).trigger('click');
+ expect(revealButton.exists()).toBe(true);
+
+ const tooltip = getBinding(revealButton.element, 'gl-tooltip');
- expect(mockSelect).toHaveBeenCalled();
+ expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelReveal);
});
- });
- describe('when label is clicked', () => {
- it('selects input value', async () => {
- const mockSelect = jest.fn();
- wrapper.vm.$refs.input.$el.select = mockSelect;
- await wrapper.find('label').trigger('click');
+ it('renders a hide button once revealed', async () => {
+ const revealButton = findRevealButton();
+ await revealButton.trigger('click');
+ await nextTick();
+
+ const hideButton = findHideButton();
+ expect(hideButton.exists()).toBe(true);
+
+ const tooltip = getBinding(hideButton.element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelHide);
+ });
+
+ it('emits `input` event when editing', () => {
+ expect(wrapper.emitted('input')).toBeUndefined();
+ const newVal = 'ding!';
+
+ const input = findFormInput();
+ input.element.value = newVal;
+ input.trigger('input');
+
+ expect(wrapper.emitted()).toHaveProperty('input');
+ expect(wrapper.emitted('input')).toHaveLength(1);
+ expect(wrapper.emitted('input')[0][0]).toBe(newVal);
+ });
+
+ it('copies updated value to clipboard after editing', async () => {
+ const writeTextSpy = jest.spyOn(global.navigator.clipboard, 'writeText');
+
+ triggerCopyShortcut();
+ await nextTick();
- expect(mockSelect).toHaveBeenCalled();
+ expect(wrapper.emitted('copy')).toHaveLength(1);
+ expect(writeTextSpy).toHaveBeenCalledWith(valueProp);
+
+ const updatedValue = 'wow amazing';
+ wrapper.setProps({ value: updatedValue });
+ await nextTick();
+
+ triggerCopyShortcut();
+ await nextTick();
+
+ expect(wrapper.emitted('copy')).toHaveLength(2);
+ expect(writeTextSpy).toHaveBeenCalledWith(updatedValue);
+ });
+
+ describe('when input is clicked', () => {
+ it('shows the actual value', async () => {
+ const input = findFormInput();
+
+ expectInputToBeMasked();
+ await findFormInput().trigger('click');
+
+ expect(input.element.value).toBe(valueProp);
+ });
+
+ it('ensures the selection start/end are in the correct position once the actual value has been revealed', async () => {
+ const input = findFormInput();
+ const selectionStart = 2;
+ const selectionEnd = 4;
+
+ input.element.setSelectionRange(selectionStart, selectionEnd);
+ await input.trigger('click');
+
+ expect(input.element.selectionStart).toBe(selectionStart);
+ expect(input.element.selectionEnd).toBe(selectionEnd);
+ });
});
});
});
@@ -219,7 +341,7 @@ describe('InputCopyToggleVisibility', () => {
});
it('displays value', () => {
- expect(findFormInput().element.value).toBe(valueProp);
+ expectInputToBeRevealed();
});
itDoesNotModifyCopyEvent();
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index afabcc49017..092d3c07716 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -74,6 +74,18 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do
it { is_expected.to eq("can contain only letters, digits, emoji, '_', '.', dash, space, parenthesis. It must start with letter, digit, emoji or '_'.") }
end
+ describe '.slack_link_regex' do
+ subject { described_class.slack_link_regex }
+
+ it { is_expected.not_to match('http://custom-url.com|click here') }
+ it { is_expected.not_to match('custom-url.com|any-Charact3r$') }
+ it { is_expected.not_to match("&lt;custom-url.com|any-Charact3r$&gt;") }
+
+ it { is_expected.to match('<http://custom-url.com|click here>') }
+ it { is_expected.to match('<custom-url.com|any-Charact3r$>') }
+ it { is_expected.to match('<any-Charact3r$|any-Charact3r$>') }
+ end
+
describe '.bulk_import_destination_namespace_path_regex_message' do
subject { described_class.bulk_import_destination_namespace_path_regex_message }
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric_spec.rb
deleted file mode 100644
index 35e5d7f2796..00000000000
--- a/spec/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric_spec.rb
+++ /dev/null
@@ -1,72 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Usage::Metrics::Instrumentations::WorkItemsActivityAggregatedMetric do
- let(:metric_definition) do
- {
- data_source: 'redis_hll',
- time_frame: time_frame,
- options: {
- aggregate: {
- operator: 'OR'
- },
- events: %w[
- users_creating_work_items
- users_updating_work_item_title
- users_updating_work_item_dates
- users_updating_work_item_labels
- users_updating_work_item_milestone
- users_updating_work_item_iteration
- ]
- }
- }
- end
-
- around do |example|
- freeze_time { example.run }
- end
-
- where(:time_frame) { [['28d'], ['7d']] }
-
- with_them do
- describe '#available?' do
- it 'returns false without track_work_items_activity feature' do
- stub_feature_flags(track_work_items_activity: false)
-
- expect(described_class.new(metric_definition).available?).to eq(false)
- end
-
- it 'returns true with track_work_items_activity feature' do
- stub_feature_flags(track_work_items_activity: true)
-
- expect(described_class.new(metric_definition).available?).to eq(true)
- end
- end
-
- describe '#value', :clean_gitlab_redis_shared_state do
- let(:counter) { Gitlab::UsageDataCounters::HLLRedisCounter }
- let(:author1_id) { 1 }
- let(:author2_id) { 2 }
- let(:event_time) { 1.week.ago }
-
- before do
- counter.track_event(:users_creating_work_items, values: author1_id, time: event_time)
- end
-
- it 'has correct value after events are tracked', :aggregate_failures do
- expect do
- counter.track_event(:users_updating_work_item_title, values: author1_id, time: event_time)
- counter.track_event(:users_updating_work_item_dates, values: author1_id, time: event_time)
- counter.track_event(:users_updating_work_item_labels, values: author1_id, time: event_time)
- counter.track_event(:users_updating_work_item_milestone, values: author1_id, time: event_time)
- end.to not_change { described_class.new(metric_definition).value }
-
- expect do
- counter.track_event(:users_updating_work_item_iteration, values: author2_id, time: event_time)
- counter.track_event(:users_updating_weight_estimate, values: author1_id, time: event_time)
- end.to change { described_class.new(metric_definition).value }.from(1).to(2)
- end
- end
- end
-end
diff --git a/spec/lib/slack_markdown_sanitizer_spec.rb b/spec/lib/slack_markdown_sanitizer_spec.rb
index f4042439213..d9552542465 100644
--- a/spec/lib/slack_markdown_sanitizer_spec.rb
+++ b/spec/lib/slack_markdown_sanitizer_spec.rb
@@ -20,4 +20,21 @@ RSpec.describe SlackMarkdownSanitizer, feature_category: :integrations do
end
end
end
+
+ describe '.sanitize_slack_link' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:input, :output) do
+ '' | ''
+ '[label](url)' | '[label](url)'
+ '<url|label>' | '&lt;url|label&gt;'
+ '<a href="url">label</a>' | '<a href="url">label</a>'
+ end
+
+ with_them do
+ it 'returns the expected output' do
+ expect(described_class.sanitize_slack_link(input)).to eq(output)
+ end
+ end
+ end
end
diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb
index 6192a271028..584f9b010ad 100644
--- a/spec/models/abuse_report_spec.rb
+++ b/spec/models/abuse_report_spec.rb
@@ -164,6 +164,34 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do
expect(described_class.by_category('phishing')).to match_array([report2])
end
end
+
+ describe '.aggregated_by_user_and_category' do
+ let_it_be(:report3) { create(:abuse_report, category: report1.category, user: report1.user) }
+ let_it_be(:report4) { create(:abuse_report, category: 'phishing', user: report1.user) }
+ let_it_be(:report5) { create(:abuse_report, category: report1.category, user: build(:user)) }
+
+ let_it_be(:sort_by_count) { true }
+
+ subject(:aggregated) { described_class.aggregated_by_user_and_category(sort_by_count) }
+
+ context 'when sort_by_count = true' do
+ it 'sorts by aggregated_count in descending order and created_at in descending order' do
+ expect(aggregated).to eq([report1, report5, report4, report])
+ end
+
+ it 'returns count with aggregated reports' do
+ expect(aggregated[0].count).to eq(2)
+ end
+ end
+
+ context 'when sort_by_count = false' do
+ let_it_be(:sort_by_count) { false }
+
+ it 'does not sort using a specific order' do
+ expect(aggregated).to match_array([report, report1, report4, report5])
+ end
+ end
+ end
end
describe 'before_validation' do
diff --git a/spec/models/integrations/chat_message/issue_message_spec.rb b/spec/models/integrations/chat_message/issue_message_spec.rb
index cd40e4c361e..14451427a5a 100644
--- a/spec/models/integrations/chat_message/issue_message_spec.rb
+++ b/spec/models/integrations/chat_message/issue_message_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Integrations::ChatMessage::IssueMessage do
+RSpec.describe Integrations::ChatMessage::IssueMessage, feature_category: :integrations do
subject { described_class.new(args) }
let(:args) do
@@ -24,7 +24,7 @@ RSpec.describe Integrations::ChatMessage::IssueMessage do
url: 'http://url.com',
action: 'open',
state: 'opened',
- description: 'issue description'
+ description: 'issue description <http://custom-url.com|CLICK HERE>'
}
}
end
@@ -45,7 +45,7 @@ RSpec.describe Integrations::ChatMessage::IssueMessage do
end
context 'open' do
- it 'returns a message regarding opening of issues' do
+ it 'returns a slack-link sanitized message regarding opening of issues' do
expect(subject.pretext).to eq(
'[<http://somewhere.com|project_name>] Issue <http://url.com|#100 Issue title> opened by Test User (test.user)')
expect(subject.attachments).to eq(
@@ -53,7 +53,7 @@ RSpec.describe Integrations::ChatMessage::IssueMessage do
{
title: "#100 Issue title",
title_link: "http://url.com",
- text: "issue description",
+ text: "issue description &lt;http://custom-url.com|CLICK HERE&gt;",
color: color
}
])
@@ -96,7 +96,7 @@ RSpec.describe Integrations::ChatMessage::IssueMessage do
it 'returns a message regarding opening of issues' do
expect(subject.pretext).to eq(
'[[project_name](http://somewhere.com)] Issue [#100 Issue title](http://url.com) opened by Test User (test.user)')
- expect(subject.attachments).to eq('issue description')
+ expect(subject.attachments).to eq('issue description &lt;http://custom-url.com|CLICK HERE&gt;')
expect(subject.activity).to eq({
title: 'Issue opened by Test User (test.user)',
subtitle: 'in [project_name](http://somewhere.com)',
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index e99d77dc0a0..d56bc210d82 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -795,6 +795,24 @@ RSpec.describe Note, feature_category: :team_planning do
expect(note.system_note_visible_for?(nil)).to be_truthy
end
end
+
+ context 'when referenced resource is not present' do
+ let(:note) do
+ create :note, noteable: ext_issue, project: ext_proj, note: "mentioned in merge request !1", system: true
+ end
+
+ it "returns true for other users" do
+ expect(note.system_note_visible_for?(private_user)).to be_truthy
+ end
+
+ it "returns true if user visible reference count set" do
+ note.user_visible_reference_count = 0
+ note.total_reference_count = 0
+
+ expect(note).not_to receive(:reference_mentionables)
+ expect(note.system_note_visible_for?(ext_issue.author)).to be_truthy
+ end
+ end
end
describe '#system_note_with_references?' do
diff --git a/spec/serializers/admin/abuse_report_entity_spec.rb b/spec/serializers/admin/abuse_report_entity_spec.rb
index 003d76a172f..c7f57258f40 100644
--- a/spec/serializers/admin/abuse_report_entity_spec.rb
+++ b/spec/serializers/admin/abuse_report_entity_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe Admin::AbuseReportEntity, feature_category: :insider_threat do
:category,
:created_at,
:updated_at,
+ :count,
:reported_user,
:reporter,
:report_path
diff --git a/spec/support/shared_examples/features/runners_shared_examples.rb b/spec/support/shared_examples/features/runners_shared_examples.rb
index 54a4db0e81d..0c043f48c5f 100644
--- a/spec/support/shared_examples/features/runners_shared_examples.rb
+++ b/spec/support/shared_examples/features/runners_shared_examples.rb
@@ -57,7 +57,7 @@ RSpec.shared_examples 'shows and resets runner registration token' do
click_on dropdown_text
click_on 'Click to reveal'
- expect(old_registration_token).not_to eq registration_token
+ expect(find_field('token-value').value).not_to eq old_registration_token
end
end
end
diff --git a/spec/support/shared_examples/usage_data_counters/work_item_activity_unique_counter_shared_examples.rb b/spec/support/shared_examples/usage_data_counters/work_item_activity_unique_counter_shared_examples.rb
index 4655585a092..83119046377 100644
--- a/spec/support/shared_examples/usage_data_counters/work_item_activity_unique_counter_shared_examples.rb
+++ b/spec/support/shared_examples/usage_data_counters/work_item_activity_unique_counter_shared_examples.rb
@@ -1,41 +1,27 @@
# frozen_string_literal: true
-RSpec.shared_examples 'counter that does not track the event' do
- it 'does not track the event' do
- expect { 3.times { track_event } }.to not_change {
+RSpec.shared_examples 'work item unique counter' do
+ it 'tracks a unique event only once' do
+ expect { 3.times { track_event } }.to change {
Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(
event_names: event_name,
start_date: 2.weeks.ago,
end_date: 2.weeks.from_now
)
- }
+ }.by(1)
end
-end
-RSpec.shared_examples 'work item unique counter' do
- context 'when track_work_items_activity FF is enabled' do
- it 'tracks a unique event only once' do
- expect { 3.times { track_event } }.to change {
+ context 'when author is nil' do
+ let(:user) { nil }
+
+ it 'does not track the event' do
+ expect { 3.times { track_event } }.to not_change {
Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(
event_names: event_name,
start_date: 2.weeks.ago,
end_date: 2.weeks.from_now
)
- }.by(1)
+ }
end
-
- context 'when author is nil' do
- let(:user) { nil }
-
- it_behaves_like 'counter that does not track the event'
- end
- end
-
- context 'when track_work_items_activity FF is disabled' do
- before do
- stub_feature_flags(track_work_items_activity: false)
- end
-
- it_behaves_like 'counter that does not track the event'
end
end
diff --git a/yarn.lock b/yarn.lock
index 33df6b05a57..b50a0ba3861 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1074,7 +1074,7 @@
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.6.tgz#d21ace437cc919cdd8f1640302fa8851e65e75c0"
integrity sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==
-"@floating-ui/dom@1.2.9":
+"@floating-ui/dom@1.2.9", "@floating-ui/dom@^1.2.9":
version "1.2.9"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.9.tgz#b9ed1c15d30963419a6736f1b7feb350dd49c603"
integrity sha512-sosQxsqgxMNkV3C+3UqTS6LxP7isRLwX8WMepp843Rb3/b0Wz8+MdUkxJksByip3C2WwLugLHN1b4ibn//zKwQ==