summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-04-19 15:19:34 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2023-04-19 15:19:34 +0000
commitb6d63c915a91aeb7a4437349c53e68be8c50cf4e (patch)
tree8617959c1d6b9137e4cefad06aedbf574295cd6c
parent2017bc90a671eac669f0114b6ef508e151409c4f (diff)
downloadgitlab-ce-b6d63c915a91aeb7a4437349c53e68be8c50cf4e.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop_todo/rspec/context_wording.yml1
-rw-r--r--.rubocop_todo/rspec/factory_bot/avoid_create.yml1
-rw-r--r--.rubocop_todo/rspec/missing_feature_category.yml1
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.checksum2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/entrypoints/super_sidebar.js3
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue88
-rw-r--r--app/assets/javascripts/invite_members/constants.js1
-rw-r--r--app/assets/javascripts/invite_members/utils/member_utils.js10
-rw-r--r--app/assets/javascripts/issues/constants.js5
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js6
-rw-r--r--app/assets/javascripts/issues/list/graphql.js5
-rw-r--r--app/assets/javascripts/issues/list/index.js4
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue165
-rw-r--r--app/assets/javascripts/issues/show/components/new_header_actions_popover.vue82
-rw-r--r--app/assets/javascripts/issues/show/constants.js2
-rw-r--r--app/assets/javascripts/issues/show/index.js2
-rw-r--r--app/assets/javascripts/right_sidebar.js6
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue27
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue15
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js21
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue58
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue80
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue29
-rw-r--r--app/assets/javascripts/super_sidebar/constants.js13
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js26
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js40
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue8
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js1
-rw-r--r--app/assets/stylesheets/components/detail_page.scss4
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss30
-rw-r--r--app/assets/stylesheets/page_bundles/issuable.scss10
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss4
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss4
-rw-r--r--app/controllers/projects/incidents_controller.rb1
-rw-r--r--app/controllers/projects/issues_controller.rb1
-rw-r--r--app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb20
-rw-r--r--app/graphql/resolvers/projects/commit_parent_names_resolver.rb33
-rw-r--r--app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb25
-rw-r--r--app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb20
-rw-r--r--app/graphql/types/project_type.rb10
-rw-r--r--app/graphql/types/projects/commit_parent_names_type.rb14
-rw-r--r--app/helpers/issuables_helper.rb4
-rw-r--r--app/helpers/issues_helper.rb5
-rw-r--r--app/helpers/merge_requests_helper.rb8
-rw-r--r--app/helpers/nav_helper.rb2
-rw-r--r--app/models/application_setting.rb1
-rw-r--r--app/views/layouts/nav/_top_bar.html.haml2
-rw-r--r--app/views/projects/issues/_new_branch.html.haml14
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml3
-rw-r--r--app/views/shared/_label.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml31
-rw-r--r--app/views/shared/issue_type/_details_header.html.haml2
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml2
-rw-r--r--config/feature_flags/development/super_sidebar_peek.yml8
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--db/docs/batched_background_migrations/update_code_suggestions_for_namespace_settings.yml6
-rw-r--r--db/migrate/20230406095544_add_pa_configurator_base_to_app_settings.rb13
-rw-r--r--db/migrate/20230410092450_add_product_analytics_instrumentation_key_to_project_settings.rb21
-rw-r--r--db/migrate/20230413153140_add_text_limit_to_project_settings_instrumentation_key.rb13
-rw-r--r--db/post_migrate/20230405132104_remove_saml_provider_and_identities_non_root_group.rb34
-rw-r--r--db/post_migrate/20230405132855_remove_scim_token_and_scim_identity_non_root_group.rb34
-rw-r--r--db/post_migrate/20230418164957_queue_update_code_suggestions_for_namespace_settings.rb25
-rw-r--r--db/schema_migrations/202304051321041
-rw-r--r--db/schema_migrations/202304051328551
-rw-r--r--db/schema_migrations/202304060955441
-rw-r--r--db/schema_migrations/202304100924501
-rw-r--r--db/schema_migrations/202304131531401
-rw-r--r--db/schema_migrations/202304181649571
-rw-r--r--db/structure.sql4
-rw-r--r--doc/api/graphql/reference/index.md35
-rw-r--r--doc/architecture/blueprints/clickhouse_usage/index.md6
-rw-r--r--doc/architecture/blueprints/clickhouse_usage/self_managed_costs_and_requirements/index.md65
-rw-r--r--doc/development/integrations/index.md44
-rw-r--r--doc/operations/incident_management/manage_incidents.md20
-rw-r--r--doc/security/rate_limits.md8
-rw-r--r--doc/user/group/epics/img/button_close_epic.pngbin13850 -> 0 bytes
-rw-r--r--doc/user/group/epics/manage_epics.md11
-rw-r--r--doc/user/product_analytics/index.md21
-rw-r--r--doc/user/profile/comment_templates.md9
-rw-r--r--doc/user/profile/notifications.md2
-rw-r--r--doc/user/project/issues/create_issues.md2
-rw-r--r--doc/user/project/issues/managing_issues.md12
-rw-r--r--doc/user/project/merge_requests/index.md5
-rw-r--r--doc/user/report_abuse.md4
-rw-r--r--lib/gitlab/application_rate_limiter.rb1
-rw-r--r--lib/gitlab/background_migration/update_code_suggestions_for_namespace_settings.rb24
-rw-r--r--lib/gitlab/gon_helper.rb1
-rw-r--r--locale/gitlab.pot60
-rw-r--r--spec/features/ide/user_opens_merge_request_spec.rb4
-rw-r--r--spec/features/incidents/incident_details_spec.rb2
-rw-r--r--spec/features/issues/discussion_lock_spec.rb1
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb3
-rw-r--r--spec/features/issues/issue_detail_spec.rb5
-rw-r--r--spec/features/issues/issue_sidebar_spec.rb4
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb3
-rw-r--r--spec/features/issues/user_toggles_subscription_spec.rb4
-rw-r--r--spec/features/merge_request/user_manages_subscription_spec.rb4
-rw-r--r--spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb2
-rw-r--r--spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb3
-rw-r--r--spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb2
-rw-r--r--spec/features/projects/issuable_templates_spec.rb2
-rw-r--r--spec/features/reportable_note/issue_spec.rb4
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js217
-rw-r--r--spec/frontend/invite_members/mock_data/member_modal.js31
-rw-r--r--spec/frontend/invite_members/utils/member_utils_spec.js30
-rw-r--r--spec/frontend/issues/show/components/header_actions_spec.js317
-rw-r--r--spec/frontend/issues/show/components/new_header_actions_popover_spec.js77
-rw-r--r--spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js21
-rw-r--r--spec/frontend/super_sidebar/components/super_sidebar_spec.js117
-rw-r--r--spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js106
-rw-r--r--spec/frontend/super_sidebar/components/user_bar_spec.js17
-rw-r--r--spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js106
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestions_spec.js54
-rw-r--r--spec/helpers/issues_helper_spec.rb10
-rw-r--r--spec/lib/gitlab/background_migration/update_code_suggestions_for_namespace_settings_spec.rb48
-rw-r--r--spec/migrations/20230418164957_queue_update_code_suggestions_for_namespace_settings_spec.rb26
-rw-r--r--spec/migrations/remove_saml_provider_and_identities_non_root_group_spec.rb53
-rw-r--r--spec/migrations/remove_scim_token_and_scim_identity_non_root_group_spec.rb58
-rw-r--r--spec/models/merge_request_spec.rb4
-rw-r--r--spec/requests/api/graphql/project/branches_tipping_at_commit_spec.rb67
-rw-r--r--spec/requests/api/graphql/project/tags_tipping_at_commit_spec.rb67
-rw-r--r--spec/requests/api/project_attributes.yml1
124 files changed, 2294 insertions, 634 deletions
diff --git a/.rubocop_todo/rspec/context_wording.yml b/.rubocop_todo/rspec/context_wording.yml
index a7ec2fcd55a..ced205ff8ea 100644
--- a/.rubocop_todo/rspec/context_wording.yml
+++ b/.rubocop_todo/rspec/context_wording.yml
@@ -533,7 +533,6 @@ RSpec/ContextWording:
- 'ee/spec/policies/project_snippet_policy_spec.rb'
- 'ee/spec/policies/protected_branch_policy_spec.rb'
- 'ee/spec/policies/saml_provider_policy_spec.rb'
- - 'ee/spec/presenters/approval_rule_presenter_spec.rb'
- 'ee/spec/presenters/audit_event_presenter_spec.rb'
- 'ee/spec/presenters/ci/build_runner_presenter_spec.rb'
- 'ee/spec/presenters/ee/issue_presenter_spec.rb'
diff --git a/.rubocop_todo/rspec/factory_bot/avoid_create.yml b/.rubocop_todo/rspec/factory_bot/avoid_create.yml
index 1c3654c670d..752f10101b8 100644
--- a/.rubocop_todo/rspec/factory_bot/avoid_create.yml
+++ b/.rubocop_todo/rspec/factory_bot/avoid_create.yml
@@ -100,7 +100,6 @@ RSpec/FactoryBot/AvoidCreate:
- 'ee/spec/mailers/emails/user_cap_spec.rb'
- 'ee/spec/mailers/license_mailer_spec.rb'
- 'ee/spec/mailers/notify_spec.rb'
- - 'ee/spec/presenters/approval_rule_presenter_spec.rb'
- 'ee/spec/presenters/audit_event_presenter_spec.rb'
- 'ee/spec/presenters/ci/build_runner_presenter_spec.rb'
- 'ee/spec/presenters/ci/minutes/usage_presenter_spec.rb'
diff --git a/.rubocop_todo/rspec/missing_feature_category.yml b/.rubocop_todo/rspec/missing_feature_category.yml
index 84ff2f52c7b..c94541ad8a2 100644
--- a/.rubocop_todo/rspec/missing_feature_category.yml
+++ b/.rubocop_todo/rspec/missing_feature_category.yml
@@ -1346,7 +1346,6 @@ RSpec/MissingFeatureCategory:
- 'ee/spec/policies/user_policy_spec.rb'
- 'ee/spec/policies/vulnerability_policy_spec.rb'
- 'ee/spec/presenters/analytics/cycle_analytics/stage_presenter_spec.rb'
- - 'ee/spec/presenters/approval_rule_presenter_spec.rb'
- 'ee/spec/presenters/audit_event_presenter_spec.rb'
- 'ee/spec/presenters/ci/build_presenter_spec.rb'
- 'ee/spec/presenters/ci/build_runner_presenter_spec.rb'
diff --git a/Gemfile b/Gemfile
index 0fc5773ef8d..232205824da 100644
--- a/Gemfile
+++ b/Gemfile
@@ -532,7 +532,7 @@ gem 'lograge', '~> 0.5'
gem 'grape_logging', '~> 1.8'
# DNS Lookup
-gem 'gitlab-net-dns', '~> 0.9.1'
+gem 'gitlab-net-dns', '~> 0.9.2'
# Countries list
gem 'countries', '~> 4.0.0'
diff --git a/Gemfile.checksum b/Gemfile.checksum
index 40470b66dac..ee4978bb8f0 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -214,7 +214,7 @@
{"name":"gitlab-license","version":"2.2.1","platform":"ruby","checksum":"39fcf6be8b2887df8afe01b5dcbae8d08b7c5d937ff56b0fb40484a8c4f02d30"},
{"name":"gitlab-mail_room","version":"0.0.23","platform":"ruby","checksum":"23564fa4dab24ec5011d4c64a801fc0228301d5b0f046a26a1d8e96e36c19997"},
{"name":"gitlab-markup","version":"1.9.0","platform":"ruby","checksum":"7eda045a08ec2d110084252fa13a8c9eac8bdac0e302035ca7db4b82bcbd7ed4"},
-{"name":"gitlab-net-dns","version":"0.9.1","platform":"ruby","checksum":"bcd1a08dcb31b731e8ff602d828de619d2d9f53f5812f6abacf11c720873d4cb"},
+{"name":"gitlab-net-dns","version":"0.9.2","platform":"ruby","checksum":"f726d978479d43810819f12a45c0906d775a07e34df111bbe693fffbbef3059d"},
{"name":"gitlab-styles","version":"10.0.0","platform":"ruby","checksum":"8a1b20f7b5f351605ff4ed4ec648ef37226f2774d1e1377ed99389448d6913f0"},
{"name":"gitlab_chronic_duration","version":"0.10.6.2","platform":"ruby","checksum":"6dda4cfe7dca9b958f163ac8835c3d9cc70cf8df8cbb89bb2fbf9ba4375105fb"},
{"name":"gitlab_omniauth-ldap","version":"2.2.0","platform":"ruby","checksum":"bb4d20acb3b123ed654a8f6a47d3fac673ece7ed0b6992edb92dca14bad2838c"},
diff --git a/Gemfile.lock b/Gemfile.lock
index 73480837892..a40830271f7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -623,7 +623,7 @@ GEM
net-imap (>= 0.2.1)
oauth2 (>= 1.4.4, < 3)
gitlab-markup (1.9.0)
- gitlab-net-dns (0.9.1)
+ gitlab-net-dns (0.9.2)
gitlab-styles (10.0.0)
rubocop (~> 1.43.0)
rubocop-graphql (~> 0.18)
@@ -1753,7 +1753,7 @@ DEPENDENCIES
gitlab-license (~> 2.2.1)
gitlab-mail_room (~> 0.0.23)
gitlab-markup (~> 1.9.0)
- gitlab-net-dns (~> 0.9.1)
+ gitlab-net-dns (~> 0.9.2)
gitlab-sidekiq-fetcher!
gitlab-styles (~> 10.0.0)
gitlab_chronic_duration (~> 0.10.6.2)
diff --git a/app/assets/javascripts/entrypoints/super_sidebar.js b/app/assets/javascripts/entrypoints/super_sidebar.js
index 308077f98b1..6e88a998096 100644
--- a/app/assets/javascripts/entrypoints/super_sidebar.js
+++ b/app/assets/javascripts/entrypoints/super_sidebar.js
@@ -1,5 +1,6 @@
import '~/webpack';
import '~/commons';
-import { initSuperSidebar } from '~/super_sidebar/super_sidebar_bundle';
+import { initSuperSidebar, initSuperSidebarToggle } from '~/super_sidebar/super_sidebar_bundle';
initSuperSidebar();
+initSuperSidebarToggle();
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index a2d3b47d8f0..e99a61caf3f 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -14,18 +14,20 @@ import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_b
import Api from '~/api';
import Tracking from '~/tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { getParameterValues } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
import {
+ memberName,
+ triggerExternalAlert,
+ qualifiesForTasksToBeDone,
+} from 'ee_else_ce/invite_members/utils/member_utils';
+import {
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
MEMBER_MODAL_LABELS,
- LEARN_GITLAB,
INVITE_MEMBER_MODAL_TRACKING_CATEGORY,
} from '../constants';
import eventHub from '../event_hub';
import { responseFromSuccess } from '../utils/response_message_parser';
-import { memberName } from '../utils/member_utils';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import {
displaySuccessfulInvitationAlert,
@@ -169,11 +171,7 @@ export default {
);
},
tasksToBeDoneEnabled() {
- return (
- (getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
- this.isOnLearnGitlab) &&
- this.tasksToBeDoneOptions.length
- );
+ return qualifiesForTasksToBeDone(this.source) && this.tasksToBeDoneOptions.length;
},
showTasksToBeDone() {
return (
@@ -192,9 +190,6 @@ export default {
? this.selectedTaskProject.id
: '';
},
- isOnLearnGitlab() {
- return this.source === LEARN_GITLAB;
- },
showUserLimitNotification() {
return !isEmpty(this.usersLimitDataset.alertVariant);
},
@@ -283,7 +278,24 @@ export default {
this.shouldShowEmptyInvitesAlert = true;
this.$refs.alerts.focus();
},
- sendInvite({ accessLevel, expiresAt }) {
+ getInvitePayload({ accessLevel, expiresAt }) {
+ const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
+
+ const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
+ const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+
+ return {
+ format: 'json',
+ expires_at: expiresAt,
+ access_level: accessLevel,
+ invite_source: this.source,
+ tasks_to_be_done: this.tasksToBeDoneForPost,
+ tasks_project_id: this.tasksProjectForPost,
+ ...email,
+ ...userId,
+ };
+ },
+ async sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
this.clearValidation();
@@ -292,40 +304,28 @@ export default {
return;
}
- const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
+ this.trackInviteMembersForTask();
const apiAddByInvite = this.isProject
? Api.inviteProjectMembers.bind(Api)
: Api.inviteGroupMembers.bind(Api);
- const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
- const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+ try {
+ const payload = this.getInvitePayload({ accessLevel, expiresAt });
+ const response = await apiAddByInvite(this.id, payload);
- this.trackinviteMembersForTask();
+ const { error, message } = responseFromSuccess(response);
- apiAddByInvite(this.id, {
- format: 'json',
- expires_at: expiresAt,
- access_level: accessLevel,
- invite_source: this.source,
- tasks_to_be_done: this.tasksToBeDoneForPost,
- tasks_project_id: this.tasksProjectForPost,
- ...email,
- ...userId,
- })
- .then((response) => {
- const { error, message } = responseFromSuccess(response);
-
- if (error) {
- this.showMemberErrors(message);
- } else {
- this.onInviteSuccess();
- }
- })
- .catch((e) => this.showInvalidFeedbackMessage(e))
- .finally(() => {
- this.isLoading = false;
- });
+ if (error) {
+ this.showMemberErrors(message);
+ } else {
+ this.onInviteSuccess();
+ }
+ } catch (e) {
+ this.showInvalidFeedbackMessage(e);
+ } finally {
+ this.isLoading = false;
+ }
},
showMemberErrors(message) {
this.invalidMembers = message;
@@ -335,7 +335,7 @@ export default {
// initial token creation hits this and nothing is found... so safe navigation
return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
},
- trackinviteMembersForTask() {
+ trackInviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
this.track(INVITE_MEMBERS_FOR_TASK.submit, { label, property });
@@ -367,9 +367,7 @@ export default {
}
},
showSuccessMessage() {
- if (this.isOnLearnGitlab) {
- eventHub.$emit('showSuccessfulInvitationsAlert');
- } else {
+ if (!triggerExternalAlert(this.source)) {
this.$toast.show(this.$options.labels.toastMessageSuccessful);
}
@@ -421,7 +419,9 @@ export default {
@access-level="onAccessLevelUpdate"
>
<template #intro-text-before>
- <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
+ <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1">
+ <gl-emoji data-name="tada" />
+ </div>
</template>
<template #intro-text-after>
<br />
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index f373ef81e68..d5e9e498c6b 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -150,7 +150,6 @@ export const GROUP_MODAL_LABELS = {
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
};
-export const LEARN_GITLAB = 'learn_gitlab';
export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed';
export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal';
diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js
index d85162626f1..240a3a89686 100644
--- a/app/assets/javascripts/invite_members/utils/member_utils.js
+++ b/app/assets/javascripts/invite_members/utils/member_utils.js
@@ -1,4 +1,14 @@
+import { getParameterValues } from '~/lib/utils/url_utility';
+
export function memberName(member) {
// user defined tokens(invites by email) will have email in `name` and will not contain `username`
return member.username || member.name;
}
+
+export function triggerExternalAlert() {
+ return false;
+}
+
+export function qualifiesForTasksToBeDone() {
+ return getParameterValues('open_modal')[0] === 'invite_members_for_task';
+}
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index d35355a8f26..371db4eacc3 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -26,3 +26,8 @@ export const IssuableStatusText = {
[STATUS_MERGED]: __('Merged'),
[STATUS_LOCKED]: __('Open'),
};
+
+export const IssuableTypeText = {
+ [TYPE_ISSUE]: __('issue'),
+ [TYPE_MERGE_REQUEST]: __('merge request'),
+};
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index c821c18bcb9..de0334b4ffe 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -432,7 +432,7 @@ export default class CreateMergeRequestDropdown {
let xhr = null;
event.preventDefault();
- if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) {
+ if (isConfidentialIssue() && !event.currentTarget.classList.contains('js-create-target')) {
this.droplab.hooks.forEach((hook) => hook.list.toggle());
return;
@@ -442,9 +442,9 @@ export default class CreateMergeRequestDropdown {
return;
}
- if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
+ if (event.currentTarget.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
- } else if (event.target.dataset.action === CREATE_BRANCH) {
+ } else if (event.currentTarget.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch();
}
diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js
index 96330f69965..e64870152bd 100644
--- a/app/assets/javascripts/issues/list/graphql.js
+++ b/app/assets/javascripts/issues/list/graphql.js
@@ -2,6 +2,8 @@ import produce from 'immer';
import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+let client;
+
const resolvers = {
Mutation: {
reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => {
@@ -23,7 +25,8 @@ const resolvers = {
};
export async function gqlClient() {
- const client = gon.features?.frontendCaching
+ if (client) return client;
+ client = gon.features?.frontendCaching
? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list' })
: createDefaultClient(resolvers);
return client;
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index 720946ea330..a97b59c1e4f 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -6,7 +6,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import JiraIssuesImportStatusApp from './components/jira_issues_import_status_app.vue';
import { gqlClient } from './graphql';
-export function mountJiraIssuesListApp() {
+export async function mountJiraIssuesListApp() {
const el = document.querySelector('.js-jira-issues-import-status-root');
if (!el) {
@@ -27,7 +27,7 @@ export function mountJiraIssuesListApp() {
el,
name: 'JiraIssuesImportStatusRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
render(createComponent) {
return createComponent(JiraIssuesImportStatusApp, {
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 84def374d13..b929c4dbae0 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -2,23 +2,36 @@
import {
GlButton,
GlDropdown,
+ GlDropdownDivider,
GlDropdownItem,
GlLink,
GlModal,
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { mapActions, mapGetters, mapState } from 'vuex';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
-import { STATUS_CLOSED, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
-import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants';
+import { STATUS_CLOSED, TYPE_INCIDENT, TYPE_ISSUE, IssuableTypeText } from '~/issues/constants';
+import {
+ ISSUE_STATE_EVENT_CLOSE,
+ ISSUE_STATE_EVENT_REOPEN,
+ NEW_ACTIONS_POPOVER_KEY,
+} from '~/issues/show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { getCookie, parseBoolean, setCookie } from '~/lib/utils/common_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__, __, sprintf } from '~/locale';
import eventHub from '~/notes/event_hub';
import Tracking from '~/tracking';
+import toast from '~/vue_shared/plugins/global_toast';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue';
+import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
+import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import issuesEventHub from '../event_hub';
import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
@@ -44,21 +57,27 @@ export default {
'The issue was successfully promoted to an epic. Redirecting to epic...',
),
reportAbuse: __('Report abuse to administrator'),
+ referenceFetchError: __('An error occurred while fetching reference'),
+ copyReferenceText: __('Copy reference'),
},
components: {
DeleteIssueModal,
GlButton,
GlDropdown,
+ GlDropdownDivider,
GlDropdownItem,
GlLink,
GlModal,
AbuseCategorySelector,
+ NewHeaderActionsPopover,
+ SidebarSubscriptionsWidget,
+ IssuableLockForm,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
- mixins: [trackingMixin],
+ mixins: [trackingMixin, glFeatureFlagMixin()],
inject: {
canCreateIssue: {
default: false,
@@ -105,15 +124,46 @@ export default {
reportedFromUrl: {
default: '',
},
+ issuableEmailAddress: {
+ default: '',
+ },
+ fullPath: {
+ default: '',
+ },
},
data() {
return {
isReportAbuseDrawerOpen: false,
};
},
+ apollo: {
+ issuableReference: {
+ query: issueReferenceQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.reference || '';
+ },
+ skip() {
+ return !this.isMrSidebarMoved;
+ },
+ error(error) {
+ createAlert({ message: this.$options.i18n.referenceFetchError });
+ Sentry.captureException(error);
+ },
+ },
+ },
computed: {
...mapState(['isToggleStateButtonLoading']),
...mapGetters(['openState', 'getBlockedByIssues']),
+ ...mapGetters(['getNoteableData']),
+ isLocked() {
+ return this.getNoteableData.discussion_locked;
+ },
isClosed() {
return this.openState === STATUS_CLOSED;
},
@@ -157,6 +207,17 @@ export default {
hasMobileDropdown() {
return this.hasDesktopDropdown || this.showToggleIssueStateButton;
},
+ copyMailAddressText() {
+ return sprintf(__('Copy %{issueType} email address'), {
+ issueType: IssuableTypeText[this.issueType],
+ });
+ },
+ isMrSidebarMoved() {
+ return this.glFeatures.movedMrSidebar;
+ },
+ showLockIssueOption() {
+ return this.isMrSidebarMoved && this.issueType === TYPE_ISSUE;
+ },
},
created() {
eventHub.$on('toggle.issuable.state', this.toggleIssueState);
@@ -166,6 +227,7 @@ export default {
},
methods: {
...mapActions(['toggleStateButtonLoading']),
+ ...mapActions(['updateLockedAttribute']),
toggleIssueState() {
if (!this.isClosed && this.getBlockedByIssues?.length) {
this.$refs.blockedByIssuesModal.show();
@@ -244,7 +306,19 @@ export default {
edit() {
issuesEventHub.$emit('open.form');
},
+ dismissPopover() {
+ if (this.isMrSidebarMoved && !parseBoolean(getCookie(`${NEW_ACTIONS_POPOVER_KEY}`))) {
+ setCookie(NEW_ACTIONS_POPOVER_KEY, true);
+ }
+ },
+ copyReference() {
+ toast(__('Reference copied'));
+ },
+ copyEmailAddress() {
+ toast(__('Email address copied'));
+ },
},
+ TYPE_ISSUE,
};
</script>
@@ -259,6 +333,21 @@ export default {
data-testid="mobile-dropdown"
:loading="isToggleStateButtonLoading"
>
+ <template v-if="isMrSidebarMoved">
+ <sidebar-subscriptions-widget
+ :iid="String(iid)"
+ :full-path="fullPath"
+ :issuable-type="$options.TYPE_ISSUE"
+ data-testid="notification-toggle"
+ />
+
+ <gl-dropdown-divider />
+ </template>
+
+ <template v-if="showLockIssueOption">
+ <issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" />
+ </template>
+
<gl-dropdown-item v-if="canUpdateIssue" @click="edit">
{{ $options.i18n.edit }}
</gl-dropdown-item>
@@ -275,9 +364,21 @@ export default {
<gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic">
{{ __('Promote to epic') }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)">
- {{ $options.i18n.reportAbuse }}
- </gl-dropdown-item>
+ <template v-if="isMrSidebarMoved">
+ <gl-dropdown-item
+ :data-clipboard-text="issuableReference"
+ data-testid="copy-reference"
+ @click="copyReference"
+ >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
+ >
+ <gl-dropdown-item
+ v-if="issuableEmailAddress"
+ :data-clipboard-text="issuableEmailAddress"
+ data-testid="copy-email"
+ @click="copyEmailAddress"
+ >{{ copyMailAddressText }}</gl-dropdown-item
+ >
+ </template>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
@@ -287,6 +388,7 @@ export default {
{{ __('Submit as spam') }}
</gl-dropdown-item>
<template v-if="canDestroyIssue">
+ <gl-dropdown-divider />
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
@@ -295,6 +397,13 @@ export default {
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
+ <gl-dropdown-item
+ v-if="!isIssueAuthor"
+ data-testid="report-abuse-item"
+ @click="toggleReportAbuseDrawer(true)"
+ >
+ {{ $options.i18n.reportAbuse }}
+ </gl-dropdown-item>
</gl-dropdown>
<gl-button
@@ -322,6 +431,7 @@ export default {
<gl-dropdown
v-if="hasDesktopDropdown"
+ id="new-actions-header-dropdown"
v-gl-tooltip.hover
class="gl-display-none gl-sm-display-inline-flex! gl-sm-ml-3"
icon="ellipsis_v"
@@ -334,7 +444,19 @@ export default {
data-testid="desktop-dropdown"
no-caret
right
+ @shown="dismissPopover"
>
+ <template v-if="isMrSidebarMoved">
+ <sidebar-subscriptions-widget
+ :iid="String(iid)"
+ :full-path="fullPath"
+ :issuable-type="$options.TYPE_ISSUE"
+ data-testid="notification-toggle"
+ />
+
+ <gl-dropdown-divider />
+ </template>
+
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
@@ -346,9 +468,24 @@ export default {
>
{{ __('Promote to epic') }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)">
- {{ $options.i18n.reportAbuse }}
- </gl-dropdown-item>
+ <template v-if="showLockIssueOption">
+ <issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" />
+ </template>
+ <template v-if="isMrSidebarMoved">
+ <gl-dropdown-item
+ :data-clipboard-text="issuableReference"
+ data-testid="copy-reference"
+ @click="copyReference"
+ >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
+ >
+ <gl-dropdown-item
+ v-if="issuableEmailAddress"
+ :data-clipboard-text="issuableEmailAddress"
+ data-testid="copy-email"
+ @click="copyEmailAddress"
+ >{{ copyMailAddressText }}</gl-dropdown-item
+ >
+ </template>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
@@ -357,8 +494,8 @@ export default {
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
-
<template v-if="canDestroyIssue">
+ <gl-dropdown-divider />
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
@@ -368,8 +505,16 @@ export default {
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
+ <gl-dropdown-item
+ v-if="!isIssueAuthor"
+ data-testid="report-abuse-item"
+ @click="toggleReportAbuseDrawer(true)"
+ >
+ {{ $options.i18n.reportAbuse }}
+ </gl-dropdown-item>
</gl-dropdown>
+ <new-header-actions-popover v-if="isMrSidebarMoved" :issue-type="issueType" />
<gl-modal
ref="blockedByIssuesModal"
modal-id="blocked-by-issues-modal"
diff --git a/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue
new file mode 100644
index 00000000000..8262b3ac0ff
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlPopover, GlButton } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import { getCookie, parseBoolean, setCookie } from '~/lib/utils/common_utils';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { NEW_ACTIONS_POPOVER_KEY } from '~/issues/show/constants';
+import { IssuableTypeText } from '~/issues/constants';
+
+export default {
+ name: 'NewHeaderActionsPopover',
+ i18n: {
+ popoverText: s__(
+ 'HeaderAction|Notifications and other %{issueType} actions have moved to this menu.',
+ ),
+ confirmButtonText: s__('HeaderAction|Okay!'),
+ },
+ components: {
+ GlPopover,
+ GlButton,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ issueType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dismissKey: NEW_ACTIONS_POPOVER_KEY,
+ popoverDismissed: parseBoolean(getCookie(`${NEW_ACTIONS_POPOVER_KEY}`)),
+ };
+ },
+ computed: {
+ popoverText() {
+ return sprintf(this.$options.i18n.popoverText, {
+ issueType: IssuableTypeText[this.issueType],
+ });
+ },
+ showPopover() {
+ return !this.popoverDismissed && this.isMrSidebarMoved;
+ },
+ isMrSidebarMoved() {
+ return this.glFeatures.movedMrSidebar;
+ },
+ },
+ methods: {
+ dismissPopover() {
+ this.popoverDismissed = true;
+ setCookie(this.dismissKey, this.popoverDismissed);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-popover
+ v-if="showPopover"
+ target="new-actions-header-dropdown"
+ container="viewport"
+ placement="left"
+ :show="showPopover"
+ triggers="manual"
+ content="text"
+ :css-classes="['gl-p-2 new-header-popover']"
+ >
+ <template #title>
+ <div class="gl-font-base gl-font-weight-normal">
+ {{ popoverText }}
+ </div>
+ </template>
+ <gl-button
+ data-testid="confirm-button"
+ variant="confirm"
+ type="submit"
+ @click="dismissPopover"
+ >{{ $options.i18n.confirmButtonText }}</gl-button
+ >
+ </gl-popover>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/constants.js b/app/assets/javascripts/issues/show/constants.js
index 4d8c11f9669..6320e4ef266 100644
--- a/app/assets/javascripts/issues/show/constants.js
+++ b/app/assets/javascripts/issues/show/constants.js
@@ -17,3 +17,5 @@ export const issueState = {
issueType: undefined,
isDirty: false,
};
+
+export const NEW_ACTIONS_POPOVER_KEY = 'new-actions-popover-viewed';
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index e677328cd2e..100abcbe1e5 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -174,6 +174,8 @@ export function initHeaderActions(store, type = '') {
reportedUserId: parseInt(el.dataset.reportedUserId, 10),
reportedFromUrl: el.dataset.reportedFromUrl,
submitAsSpamPath: el.dataset.submitAsSpamPath,
+ issuableEmailAddress: el.dataset.issuableEmailAddress,
+ fullPath: el.dataset.projectPath,
},
render: (createElement) => createElement(HeaderActions),
});
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 297b8ae1fc2..58e4553d00d 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -56,8 +56,10 @@ Sidebar.prototype.addEventListeners = function () {
const layoutPage = document.querySelector('.layout-page');
const rightSidebar = document.querySelector('.js-right-sidebar');
- updateSidebarClasses(layoutPage, rightSidebar);
- window.addEventListener('resize', () => updateSidebarClasses(layoutPage, rightSidebar));
+ if (rightSidebar.classList.contains('right-sidebar-merge-requests')) {
+ updateSidebarClasses(layoutPage, rightSidebar);
+ window.addEventListener('resize', () => updateSidebarClasses(layoutPage, rightSidebar));
+ }
}
};
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 1eff4db3970..06876546fa4 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -1,8 +1,9 @@
<script>
import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
-import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { createAlert } from '~/alert';
import toast from '~/vue_shared/plugins/global_toast';
@@ -45,10 +46,8 @@ export default {
},
computed: {
...mapGetters(['getNoteableData']),
- isMergeRequest() {
- return (
- this.getNoteableData.targetType === TYPE_MERGE_REQUEST && this.glFeatures.movedMrSidebar
- );
+ isMovedMrSidebar() {
+ return this.glFeatures.movedMrSidebar;
},
issuableDisplayName() {
const isInIssuePage = this.getNoteableData.targetType === TYPE_ISSUE;
@@ -60,7 +59,6 @@ export default {
lockStatus() {
return this.isLocked ? this.$options.locked : this.$options.unlocked;
},
-
tooltipLabel() {
return this.isLocked ? __('Locked') : __('Unlocked');
},
@@ -89,8 +87,13 @@ export default {
fullPath: this.fullPath,
})
.then(() => {
- if (this.isMergeRequest) {
- toast(this.isLocked ? __('Merge request locked.') : __('Merge request unlocked.'));
+ if (this.isMovedMrSidebar) {
+ toast(
+ sprintf(__('%{issuableDisplayName} %{lockStatus}.'), {
+ issuableDisplayName: capitalizeFirstCharacter(this.issuableDisplayName),
+ lockStatus: this.isLocked ? __('locked') : __('unlocked'),
+ }),
+ );
}
})
.catch(() => {
@@ -113,14 +116,14 @@ export default {
</script>
<template>
- <li v-if="isMergeRequest" class="gl-dropdown-item">
- <button type="button" class="dropdown-item" @click="toggleLocked">
+ <li v-if="isMovedMrSidebar" class="gl-dropdown-item">
+ <button type="button" class="dropdown-item" data-testid="issuable-lock" @click="toggleLocked">
<span class="gl-dropdown-item-text-wrapper">
<template v-if="isLocked">
- {{ __('Unlock merge request') }}
+ {{ sprintf(__('Unlock %{issuableType}'), { issuableType: issuableDisplayName }) }}
</template>
<template v-else>
- {{ __('Lock merge request') }}
+ {{ sprintf(__('Lock %{issuableType}'), { issuableType: issuableDisplayName }) }}
</template>
</span>
</button>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index 344fa880131..f2b960ed02c 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -1,12 +1,7 @@
<script>
import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/alert';
-import {
- TYPE_EPIC,
- TYPE_MERGE_REQUEST,
- WORKSPACE_GROUP,
- WORKSPACE_PROJECT,
-} from '~/issues/constants';
+import { TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -91,8 +86,8 @@ export default {
},
},
computed: {
- isMergeRequest() {
- return this.issuableType === TYPE_MERGE_REQUEST && this.glFeatures.movedMrSidebar;
+ isMovedMrSidebar() {
+ return this.glFeatures.movedMrSidebar;
},
isLoading() {
return this.$apollo.queries?.subscribed?.loading || this.loading;
@@ -148,7 +143,7 @@ export default {
});
}
- if (this.isMergeRequest) {
+ if (this.isMovedMrSidebar) {
toast(subscribed ? __('Notifications turned on.') : __('Notifications turned off.'));
}
},
@@ -187,7 +182,7 @@ export default {
</script>
<template>
- <gl-dropdown-form v-if="isMergeRequest" class="gl-dropdown-item">
+ <gl-dropdown-form v-if="isMovedMrSidebar" class="gl-dropdown-item">
<div class="gl-px-5 gl-pb-2 gl-pt-1">
<gl-toggle
:value="subscribed"
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 2c56dc34701..0bf4105fdd6 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -17,6 +17,7 @@ import { __ } from '~/locale';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import Translate from '~/vue_shared/translate';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
+import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue';
import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue';
@@ -645,7 +646,7 @@ function mountCopyEmailToClipboard() {
});
}
-export function mountMoveIssuesButton() {
+export async function mountMoveIssuesButton() {
const el = document.querySelector('.js-move-issues');
if (!el) {
@@ -658,7 +659,7 @@ export function mountMoveIssuesButton() {
el,
name: 'MoveIssuesRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
render: (createElement) =>
createElement(MoveIssuesButton, {
@@ -787,6 +788,21 @@ export function mountAssigneesDropdown() {
});
}
+function mountNewIssuePopover() {
+ const el = document.querySelector('.js-sidebar-header-popover');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ name: 'NewHeaderActionsPopover',
+ render: (createElement) =>
+ createElement(NewHeaderActionsPopover, { props: { issueType: TYPE_MERGE_REQUEST } }),
+ });
+}
+
const isAssigneesWidgetShown =
(isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget;
@@ -814,6 +830,7 @@ export function mountSidebar(mediator, store) {
mountSidebarSeverityWidget();
mountSidebarEscalationStatus();
mountMoveIssueButton();
+ mountNewIssuePopover();
}
export { getSidebarOptions };
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index e9198952382..4b54e317639 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -1,8 +1,13 @@
<script>
import { GlButton, GlCollapse } from '@gitlab/ui';
import { __ } from '~/locale';
-import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
-import { SIDEBAR_VISIBILITY_CLASS } from '../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ sidebarState,
+ SUPER_SIDEBAR_PEEK_OPEN_DELAY,
+ SUPER_SIDEBAR_PEEK_CLOSE_DELAY,
+} from '../constants';
+import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import UserBar from './user_bar.vue';
import SidebarPortalTarget from './sidebar_portal_target.vue';
import ContextSwitcherToggle from './context_switcher_toggle.vue';
@@ -11,7 +16,6 @@ import HelpCenter from './help_center.vue';
import SidebarMenu from './sidebar_menu.vue';
export default {
- SIDEBAR_VISIBILITY_CLASS,
components: {
GlButton,
GlCollapse,
@@ -22,6 +26,7 @@ export default {
SidebarMenu,
SidebarPortalTarget,
},
+ mixins: [glFeatureFlagsMixin()],
i18n: {
skipToMainContent: __('Skip to main content'),
},
@@ -32,10 +37,7 @@ export default {
},
},
data() {
- return {
- contextSwitcherOpen: false,
- isCollapsed: isCollapsed(),
- };
+ return sidebarState;
},
computed: {
menuItems() {
@@ -49,6 +51,34 @@ export default {
onContextSwitcherShown() {
this.$refs['context-switcher'].focusInput();
},
+ onHoverAreaMouseEnter() {
+ this.openPeekTimer = setTimeout(this.openPeek, SUPER_SIDEBAR_PEEK_OPEN_DELAY);
+ },
+ onHoverAreaMouseLeave() {
+ clearTimeout(this.openPeekTimer);
+ },
+ onSidebarMouseEnter() {
+ clearTimeout(this.closePeekTimer);
+ },
+ onSidebarMouseLeave() {
+ this.closePeekTimer = setTimeout(this.closePeek, SUPER_SIDEBAR_PEEK_CLOSE_DELAY);
+ },
+ closePeek() {
+ if (this.isPeek) {
+ this.isPeek = false;
+ this.isCollapsed = true;
+ }
+ },
+ openPeek() {
+ this.isPeek = true;
+ this.isCollapsed = false;
+
+ // Cancel and start the timer to close sidebar, in case the user moves
+ // the cursor fast enough away to not trigger a mouseenter event.
+ // This is cancelled if the user moves the cursor into the sidebar.
+ this.onSidebarMouseEnter();
+ this.onSidebarMouseLeave();
+ },
},
};
</script>
@@ -56,14 +86,22 @@ export default {
<template>
<div>
<div class="super-sidebar-overlay" @click="collapseSidebar"></div>
+ <div
+ v-if="!isPeek && glFeatures.superSidebarPeek"
+ class="super-sidebar-hover-area gl-fixed gl-left-0 gl-top-0 gl-bottom-0 gl-w-3"
+ data-testid="super-sidebar-hover-area"
+ @mouseenter="onHoverAreaMouseEnter"
+ @mouseleave="onHoverAreaMouseLeave"
+ ></div>
<aside
id="super-sidebar"
class="super-sidebar"
- :class="{ [$options.SIDEBAR_VISIBILITY_CLASS]: isCollapsed }"
+ :class="{ 'super-sidebar-peek': isPeek }"
data-testid="super-sidebar"
data-qa-selector="navbar"
:inert="isCollapsed"
- tabindex="-1"
+ @mouseenter="onSidebarMouseEnter"
+ @mouseleave="onSidebarMouseLeave"
>
<gl-button
class="super-sidebar-skip-to gl-sr-only-focusable gl-absolute gl-left-3 gl-right-3 gl-top-3"
@@ -72,7 +110,7 @@ export default {
>
{{ $options.i18n.skipToMainContent }}
</gl-button>
- <user-bar :sidebar-data="sidebarData" />
+ <user-bar :has-collapse-button="!isPeek" :sidebar-data="sidebarData" />
<div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden">
<div class="gl-flex-grow-1 gl-overflow-auto">
<context-switcher-toggle
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
new file mode 100644
index 00000000000..3064b91ca7d
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS, sidebarState } from '../constants';
+import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ tooltipContainer: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'right',
+ },
+ },
+ i18n: {
+ collapseSidebar: __('Collapse sidebar'),
+ expandSidebar: __('Expand sidebar'),
+ navigationSidebar: __('Navigation sidebar'),
+ },
+ data() {
+ return sidebarState;
+ },
+ computed: {
+ tooltipTitle() {
+ if (this.isPeek) return '';
+
+ return this.isCollapsed
+ ? this.$options.i18n.expandSidebar
+ : this.$options.i18n.collapseSidebar;
+ },
+ tooltip() {
+ return {
+ placement: this.tooltipPlacement,
+ container: this.tooltipContainer,
+ title: this.tooltipTitle,
+ };
+ },
+ ariaExpanded() {
+ return String(!this.isCollapsed);
+ },
+ },
+ methods: {
+ toggle() {
+ toggleSuperSidebarCollapsed(!this.isCollapsed, true);
+ this.focusOtherToggle();
+ },
+ focusOtherToggle() {
+ this.$nextTick(() => {
+ const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS;
+ const otherToggle = document.querySelector(`.${classSelector}`);
+ otherToggle?.focus();
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover="tooltip"
+ aria-controls="super-sidebar"
+ :aria-expanded="ariaExpanded"
+ :aria-label="$options.i18n.navigationSidebar"
+ icon="sidebar"
+ category="tertiary"
+ :disabled="isPeek"
+ @click="toggle"
+ />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
index e96b896825a..f311c5242f5 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -4,11 +4,12 @@ import { __, s__, sprintf } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { highCountTrim } from '~/lib/utils/text_utility';
import logo from '../../../../views/shared/_logo.svg';
-import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
+import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants';
import CreateMenu from './create_menu.vue';
import Counter from './counter.vue';
import MergeRequestMenu from './merge_request_menu.vue';
import UserMenu from './user_menu.vue';
+import SuperSidebarToggle from './super_sidebar_toggle.vue';
import { SEARCH_MODAL_ID } from './global_search/constants';
export default {
@@ -16,6 +17,7 @@ export default {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
NEXT_LABEL: 'Next',
logo,
+ JS_TOGGLE_COLLAPSE_CLASS,
SEARCH_MODAL_ID,
components: {
Counter,
@@ -28,14 +30,13 @@ export default {
import(
/* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue'
),
+ SuperSidebarToggle,
},
i18n: {
- collapseSidebar: __('Collapse sidebar'),
createNew: __('Create new...'),
homepage: __('Homepage'),
issues: __('Issues'),
mergeRequests: __('Merge requests'),
- navigationSidebar: __('Navigation sidebar'),
search: __('Search'),
searchKbdHelp: sprintf(
s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'),
@@ -52,6 +53,11 @@ export default {
},
inject: ['rootPath', 'isImpersonating'],
props: {
+ hasCollapseButton: {
+ default: true,
+ type: Boolean,
+ required: false,
+ },
sidebarData: {
type: Object,
required: true,
@@ -75,9 +81,6 @@ export default {
document.removeEventListener('todo:toggle', this.updateTodos);
},
methods: {
- collapseSidebar() {
- toggleSuperSidebarCollapsed(true, true, true);
- },
updateTodos(e) {
this.todoCount = e.detail.count || 0;
},
@@ -114,14 +117,12 @@ export default {
{{ $options.NEXT_LABEL }}
</gl-badge>
<div class="gl-flex-grow-1"></div>
- <gl-button
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.collapseSidebar"
- aria-controls="super-sidebar"
- aria-expanded="true"
- :aria-label="$options.i18n.navigationSidebar"
- icon="sidebar"
- category="tertiary"
- @click="collapseSidebar"
+ <super-sidebar-toggle
+ v-if="hasCollapseButton"
+ :class="$options.JS_TOGGLE_COLLAPSE_CLASS"
+ tooltip-placement="bottom"
+ tooltip-container="super-sidebar"
+ data-testid="super-sidebar-collapse-button"
/>
<create-menu :groups="sidebarData.create_new_menu_groups" />
diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js
index 5c4a6a9dfc1..4f5b027c138 100644
--- a/app/assets/javascripts/super_sidebar/constants.js
+++ b/app/assets/javascripts/super_sidebar/constants.js
@@ -5,16 +5,27 @@
import Vue from 'vue';
export const SIDEBAR_PORTAL_ID = 'sidebar-portal-mount';
+export const JS_TOGGLE_COLLAPSE_CLASS = 'js-super-sidebar-toggle-collapse';
+export const JS_TOGGLE_EXPAND_CLASS = 'js-super-sidebar-toggle-expand';
export const portalState = Vue.observable({
ready: false,
});
-export const SIDEBAR_VISIBILITY_CLASS = 'gl-visibility-hidden';
+export const sidebarState = Vue.observable({
+ contextSwitcherOpen: false,
+ isCollapsed: false,
+ isPeek: false,
+ openPeekTimer: null,
+ closePeekTimer: null,
+});
export const MAX_FREQUENT_PROJECTS_COUNT = 5;
export const MAX_FREQUENT_GROUPS_COUNT = 3;
+export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200;
+export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500;
+
export const TRACKING_UNKNOWN_ID = 'item_without_id';
export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown';
export const CLICK_MENU_ITEM_ACTION = 'click_menu_item';
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index 58b49f218ad..fdd29a1719c 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -3,12 +3,14 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import { initStatusTriggers } from '../header';
+import { JS_TOGGLE_EXPAND_CLASS } from './constants';
import createStore from './components/global_search/store';
import {
bindSuperSidebarCollapsedEvents,
initSuperSidebarCollapsedState,
} from './super_sidebar_collapsed_state_manager';
import SuperSidebar from './components/super_sidebar.vue';
+import SuperSidebarToggle from './components/super_sidebar_toggle.vue';
Vue.use(VueApollo);
@@ -58,4 +60,28 @@ export const initSuperSidebar = () => {
});
};
+/**
+ * Guard against multiple instantiations, since the js-* class is persisted
+ * in the Vue component.
+ */
+let toggleInstantiated = false;
+
+export const initSuperSidebarToggle = () => {
+ const el = document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`);
+
+ if (!el || toggleInstantiated) return false;
+
+ toggleInstantiated = true;
+
+ return new Vue({
+ el,
+ name: 'SuperSidebarToggleRoot',
+ render(h) {
+ // Copy classes from HAML-defined button to ensure same positioning,
+ // including JS_TOGGLE_EXPAND_CLASS.
+ return h(SuperSidebarToggle, { class: el.className });
+ },
+ });
+};
+
requestIdleCallback(initStatusTriggers);
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
index ba5495ba014..17e07146678 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
@@ -1,7 +1,7 @@
import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import { debounce } from 'lodash';
import { setCookie, getCookie } from '~/lib/utils/common_utils';
-import { SIDEBAR_VISIBILITY_CLASS } from './constants';
+import { sidebarState } from './constants';
export const SIDEBAR_COLLAPSED_CLASS = 'page-with-super-sidebar-collapsed';
export const SIDEBAR_COLLAPSED_COOKIE = 'super_sidebar_collapsed';
@@ -10,7 +10,6 @@ export const SIDEBAR_TRANSITION_DURATION = 200;
export const findPage = () => document.querySelector('.page-with-super-sidebar');
export const findSidebar = () => document.querySelector('.super-sidebar');
-export const findToggle = () => document.querySelector('.js-super-sidebar-toggle');
export const isCollapsed = () => findPage().classList.contains(SIDEBAR_COLLAPSED_CLASS);
@@ -21,35 +20,14 @@ export const isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl;
export const getCollapsedCookie = () => getCookie(SIDEBAR_COLLAPSED_COOKIE) === 'true';
-const show = (sidebar, isUserAction) => {
- sidebar.classList.remove(SIDEBAR_VISIBILITY_CLASS);
- if (isUserAction) {
- sidebar.focus();
- }
-};
-
-const hide = (sidebar, toggle, isUserAction) => {
- setTimeout(() => {
- sidebar.classList.add(SIDEBAR_VISIBILITY_CLASS);
- if (isUserAction) {
- toggle?.focus();
- }
- }, SIDEBAR_TRANSITION_DURATION);
-};
+export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => {
+ clearTimeout(sidebarState.openPeekTimer);
+ clearTimeout(sidebarState.closePeekTimer);
-export const toggleSuperSidebarCollapsed = (collapsed, saveCookie, isUserAction) => {
- const page = findPage();
- const toggle = findToggle();
- const sidebar = findSidebar();
+ findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed);
- page.classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed);
- sidebar.inert = collapsed;
-
- if (collapsed) {
- hide(sidebar, toggle, isUserAction);
- } else {
- show(sidebar, isUserAction);
- }
+ sidebarState.isPeek = false;
+ sidebarState.isCollapsed = collapsed;
if (saveCookie && isDesktopBreakpoint()) {
setCookie(SIDEBAR_COLLAPSED_COOKIE, collapsed, {
@@ -64,9 +42,5 @@ export const initSuperSidebarCollapsedState = () => {
};
export const bindSuperSidebarCollapsedEvents = () => {
- findToggle()?.addEventListener('click', () => {
- toggleSuperSidebarCollapsed(!isCollapsed(), true, true);
- });
-
window.addEventListener('resize', debounce(initSuperSidebarCollapsedState, 100));
};
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 49eb11f8081..6d1cadf15be 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -176,6 +176,12 @@ export default {
<template>
<div>
<div class="flash-container js-suggestions-flash"></div>
- <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div>
+ <div
+ v-show="isRendered"
+ ref="container"
+ v-safe-html="noteHtml"
+ data-testid="suggestions-container"
+ class="md suggestions"
+ ></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index 597268a40d3..8b523645973 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -29,6 +29,7 @@ export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing';
export const REPORT_TYPE_CORPUS_MANAGEMENT = 'corpus_management';
export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning';
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
+export const REPORT_TYPE_MANUALLY_ADDED = 'generic';
/**
* SecurityReportTypeEnum values for use with GraphQL.
diff --git a/app/assets/stylesheets/components/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss
index de8142924f9..74f61faa9ae 100644
--- a/app/assets/stylesheets/components/detail_page.scss
+++ b/app/assets/stylesheets/components/detail_page.scss
@@ -74,3 +74,7 @@
color: $gl-text-color;
}
}
+
+.new-header-popover {
+ z-index: 999;
+}
diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss
index 045ad4a8298..14eec335169 100644
--- a/app/assets/stylesheets/framework/super_sidebar.scss
+++ b/app/assets/stylesheets/framework/super_sidebar.scss
@@ -36,10 +36,6 @@
width: $super-sidebar-width;
z-index: $super-sidebar-z-index;
- &:focus-visible {
- @include gl-focus;
- }
-
&.super-sidebar-loading {
transform: translate3d(-100%, 0, 0);
@@ -49,7 +45,9 @@
}
&:not(.super-sidebar-loading) {
- transition: transform $gl-transition-duration-medium;
+ @media (prefers-reduced-motion: no-preference) {
+ transition: transform $gl-transition-duration-medium;
+ }
}
.user-bar {
@@ -178,9 +176,25 @@
display: none;
}
+.super-sidebar-peek {
+ @include gl-shadow;
+ border-right: 0;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: transform 100ms !important;
+ }
+}
+
+.super-sidebar-hover-area {
+ z-index: $super-sidebar-z-index;
+}
+
.page-with-super-sidebar {
padding-left: 0;
- transition: padding-left $gl-transition-duration-medium;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: padding-left $gl-transition-duration-medium;
+ }
&:not(.page-with-super-sidebar-collapsed) {
.super-sidebar-overlay {
@@ -211,6 +225,10 @@
.page-with-super-sidebar-collapsed {
.super-sidebar {
transform: translate3d(-100%, 0, 0);
+
+ &.super-sidebar-peek {
+ transform: translate3d(0, 0, 0);
+ }
}
@include media-breakpoint-up(xl) {
diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss
index e0fb95a1359..1b98fd4df07 100644
--- a/app/assets/stylesheets/page_bundles/issuable.scss
+++ b/app/assets/stylesheets/page_bundles/issuable.scss
@@ -165,3 +165,13 @@
border: 0;
}
}
+
+.merge-request-notification-toggle {
+ .gl-toggle {
+ @include gl-ml-auto;
+ }
+
+ .gl-toggle-label {
+ @include gl-font-weight-normal;
+ }
+}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 34171aabd3f..ef75c650853 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -1486,9 +1486,13 @@ kbd {
transform: translate3d(0, 0, 0);
}
}
+@media (prefers-reduced-motion: no-preference) {
+}
.page-with-super-sidebar {
padding-left: 0;
}
+@media (prefers-reduced-motion: no-preference) {
+}
@media (min-width: 1200px) {
.page-with-super-sidebar {
padding-left: 256px;
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index eb98f324c7e..0dfc6be356f 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -1486,9 +1486,13 @@ kbd {
transform: translate3d(0, 0, 0);
}
}
+@media (prefers-reduced-motion: no-preference) {
+}
.page-with-super-sidebar {
padding-left: 0;
}
+@media (prefers-reduced-motion: no-preference) {
+}
@media (min-width: 1200px) {
.page-with-super-sidebar {
padding-left: 256px;
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 3842a88d15b..7121096bd77 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -10,6 +10,7 @@ class Projects::IncidentsController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?)
+ push_frontend_feature_flag(:moved_mr_sidebar, project)
end
feature_category :incident_management
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index efe88d17cab..d50f681beec 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -67,6 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:epic_widget_edit_confirmation, project)
+ push_frontend_feature_flag(:moved_mr_sidebar, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
diff --git a/app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb b/app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb
new file mode 100644
index 00000000000..7e2661f3f77
--- /dev/null
+++ b/app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class BranchesTippingAtCommitResolver < RefTippingAtCommitResolver
+ MAX_LIMIT = 100
+
+ calls_gitaly!
+
+ type ::Types::Projects::CommitParentNamesType, null: true
+
+ # the methode ref_prefix is implemented
+ # because this class is prepending Resolver::CommitParentNamesResolver module
+ # through it's parent ::Resolvers::RefTippingAtCommitResolver
+ def ref_prefix
+ Gitlab::Git::BRANCH_REF_PREFIX
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/commit_parent_names_resolver.rb b/app/graphql/resolvers/projects/commit_parent_names_resolver.rb
new file mode 100644
index 00000000000..f52776d715a
--- /dev/null
+++ b/app/graphql/resolvers/projects/commit_parent_names_resolver.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ module CommitParentNamesResolver
+ extend ActiveSupport::Concern
+
+ prepended do
+ argument :commit_sha, GraphQL::Types::String,
+ required: true,
+ description: 'Project commit SHA identifier. For example, `287774414568010855642518513f085491644061`.'
+
+ argument :limit, GraphQL::Types::Int,
+ required: false,
+ description: 'Number of branch names to return.'
+
+ alias_method :project, :object
+ end
+
+ def compute_limit(limit)
+ max = self.class::MAX_LIMIT
+
+ limit ? [limit, max].min : max
+ end
+
+ def get_tipping_refs(project, sha, limit: 0)
+ # the methode ref_prefix needs to be implemented in all classes prepending this module
+ refs = project.repository.refs_by_oid(oid: sha, ref_patterns: [ref_prefix], limit: limit)
+ refs.map { |n| n.delete_prefix(ref_prefix) }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb b/app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb
new file mode 100644
index 00000000000..3259a29ac9c
--- /dev/null
+++ b/app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class RefTippingAtCommitResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ prepend CommitParentNamesResolver
+
+ type ::Types::Projects::CommitParentNamesType, null: true
+
+ authorize :read_code
+
+ def resolve(commit_sha:, limit: nil)
+ final_limit = compute_limit(limit)
+
+ names = get_tipping_refs(project, commit_sha, limit: final_limit)
+
+ {
+ names: names,
+ total_count: nil
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb b/app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb
new file mode 100644
index 00000000000..78ee9c997d5
--- /dev/null
+++ b/app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class TagsTippingAtCommitResolver < RefTippingAtCommitResolver
+ MAX_LIMIT = 100
+
+ calls_gitaly!
+
+ type ::Types::Projects::CommitParentNamesType, null: true
+
+ # the methode ref_prefix is implemented
+ # because this class is prepending Resolver::CommitParentNamesResolver module
+ # through it's parent ::Resolvers::RefTippingAtCommitResolver
+ def ref_prefix
+ Gitlab::Git::TAG_REF_PREFIX
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 203c2568ce2..5ebc1cf7ddd 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -594,6 +594,16 @@ module Types
authorize: :read_cycle_analytics,
alpha: { milestone: '15.10' }
+ field :tags_tipping_at_commit, ::Types::Projects::CommitParentNamesType,
+ null: true,
+ resolver: Resolvers::Projects::TagsTippingAtCommitResolver,
+ description: "Get tag names tipping at a given commit."
+
+ field :branches_tipping_at_commit, ::Types::Projects::CommitParentNamesType,
+ null: true,
+ resolver: Resolvers::Projects::BranchesTippingAtCommitResolver,
+ description: "Get branch names tipping at a given commit."
+
def timelog_categories
object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories)
end
diff --git a/app/graphql/types/projects/commit_parent_names_type.rb b/app/graphql/types/projects/commit_parent_names_type.rb
new file mode 100644
index 00000000000..0aa1ca768e9
--- /dev/null
+++ b/app/graphql/types/projects/commit_parent_names_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module Projects
+ # rubocop: disable Graphql/AuthorizeTypes
+ class CommitParentNamesType < BaseObject
+ graphql_name 'CommitParentNames'
+
+ field :names, [GraphQL::Types::String], null: true, description: 'Names of the commit parent (branch or tag).'
+ field :total_count, GraphQL::Types::Int, null: true, description: 'Total of parent branches or tags.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 179ce01ae44..fc2c927a2b1 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -12,8 +12,8 @@ module IssuablesHelper
end
end
- def sidebar_gutter_collapsed_class
- return "right-sidebar-expanded" if moved_mr_sidebar_enabled?
+ def sidebar_gutter_collapsed_class(is_merge_request_with_flag)
+ return "right-sidebar-expanded" if is_merge_request_with_flag
"right-sidebar-#{sidebar_gutter_collapsed? ? 'collapsed' : 'expanded'}"
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index e82f09a0a97..98c378db7d3 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -153,7 +153,7 @@ module IssuesHelper
issue.moved_from.project.service_desk_enabled? && !issue.project.service_desk_enabled?
end
- def issue_header_actions_data(project, issuable, current_user)
+ def issue_header_actions_data(project, issuable, current_user, issuable_sidebar)
new_issuable_params = { issue: {}, add_related_issue: issuable.iid }
if issuable.incident?
new_issuable_params[:issuable_template] = 'incident'
@@ -176,7 +176,8 @@ module IssuesHelper
report_abuse_path: add_category_abuse_reports_path,
reported_user_id: issuable.author.id,
reported_from_url: issue_url(issuable),
- submit_as_spam_path: mark_as_spam_project_issue_path(project, issuable)
+ submit_as_spam_path: mark_as_spam_project_issue_path(project, issuable),
+ issuable_email_address: issuable_sidebar.nil? ? '' : issuable_sidebar[:create_note_email]
}
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 9ed52df69f1..a897484853d 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -179,6 +179,10 @@ module MergeRequestsHelper
end
end
+ def moved_mr_sidebar_enabled?
+ Feature.enabled?(:moved_mr_sidebar, @project)
+ end
+
def diffs_tab_pane_data(project, merge_request, params)
{
"is-locked": merge_request.discussion_locked?,
@@ -272,10 +276,6 @@ module MergeRequestsHelper
_('%{author} requested to merge %{source_branch} %{copy_button} into %{target_branch} %{created_at}').html_safe % { author: link_to_author.html_safe, source_branch: merge_request_source_branch(merge_request).html_safe, copy_button: copy_button.html_safe, target_branch: target_branch.html_safe, created_at: time_ago_with_tooltip(merge_request.created_at, html_class: 'gl-display-inline-block').html_safe }
end
- def moved_mr_sidebar_enabled?
- Feature.enabled?(:moved_mr_sidebar, @project) && defined?(@merge_request)
- end
-
def single_file_file_by_file?
Feature.enabled?(:single_file_file_by_file, @project)
end
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index b101f184ca6..1da84bf2660 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -38,7 +38,7 @@ module NavHelper
end
def page_gutter_class
- moved_sidebar_enabled = current_controller?('merge_requests') && moved_mr_sidebar_enabled?
+ moved_sidebar_enabled = @is_merge_request_with_flag
if (page_has_markdown? || current_path?('projects/merge_requests#diffs')) && !current_controller?('conflicts')
if cookies[:collapsed_gutter] == 'true'
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index c11cd0191df..52abacfe3e8 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -709,6 +709,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
attr_encrypted :telesign_customer_xid, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :telesign_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :product_analytics_clickhouse_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :product_analytics_configurator_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :openai_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
validates :disable_feed_token,
diff --git a/app/views/layouts/nav/_top_bar.html.haml b/app/views/layouts/nav/_top_bar.html.haml
index 8968f60b106..a0e03c9c0cf 100644
--- a/app/views/layouts/nav/_top_bar.html.haml
+++ b/app/views/layouts/nav/_top_bar.html.haml
@@ -8,7 +8,7 @@
%div{ class: top_bar_class }
.top-bar-container.gl-display-flex.gl-align-items-center{ :class => top_bar_container_class }
- if show_super_sidebar?
- = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle super-sidebar-toggle gl-ml-n3 gl-mr-2', title: _('Expand sidebar'), aria: { controls: 'super-sidebar', expanded: 'false', label: _('Navigation sidebar') }, data: {toggle: 'tooltip', placement: 'right' } })
+ = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3 gl-mr-2', title: _('Expand sidebar'), aria: { controls: 'super-sidebar', expanded: 'false', label: _('Navigation sidebar') } })
- elsif defined?(@left_sidebar)
= render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3 gl-mr-2', data: { qa_selector: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } })
= render "layouts/nav/breadcrumbs/breadcrumbs"
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index f9798d25b06..90d99d51d29 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -11,18 +11,18 @@
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path(from: @issue.to_branch_name, source_project: @project, to: @project.default_branch, mr_params: { issue_iid: @issue.iid }), create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
.btn-group.unavailable
- %button.gl-button.btn{ type: 'button', disabled: 'disabled' }
+ = render Pajamas::ButtonComponent.new(button_options: { disabled: 'disabled' }) do
= gl_loading_icon(inline: true, css_class: 'js-create-mr-spinner gl-button-icon gl-display-none')
%span.text
- Checking branch availability…
+ = _('Checking branch availability…')
+
.btn-group.available.hidden
- %button.gl-button.btn.js-create-merge-request.btn-confirm{ type: 'button', data: { action: data_action } }
- = gl_loading_icon(css_class: 'js-create-mr-spinner js-spinner gl-mr-2 gl-display-none')
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'js-create-merge-request', data: { action: data_action } }) do
+ = gl_loading_icon(inline: true , css_class: 'js-create-mr-spinner js-spinner gl-display-none')
= value
- %button.gl-button.btn.btn-confirm.btn-icon.dropdown-toggle.create-merge-request-dropdown-toggle.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } }
- = sprite_icon('chevron-down')
+ = render Pajamas::ButtonComponent.new(variant: :confirm, icon: 'chevron-down', button_options: { class: 'js-dropdown-toggle dropdown-toggle create-merge-request-dropdown-toggle', data: { 'dropdown-trigger': '#create-merge-request-dropdown', display: 'static' } })
.droplab-dropdown
%ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-right.gl-show-field-errors{ class: ("create-confidential-merge-request-dropdown-menu" if can_create_confidential_merge_request?), data: { dropdown: true } }
@@ -57,7 +57,7 @@
%span.js-ref-message.form-text
.form-group
- %button.btn.gl-button.btn-confirm.js-create-target{ type: 'button', data: { action: 'create-mr' } }
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'js-create-target', data: { action: 'create-mr' } }) do
= create_mr_text
- if can_create_confidential_merge_request?
diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
index b8ee62055f0..9bfa0e7a309 100644
--- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
+++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
@@ -1,7 +1,8 @@
- display_issuable_type = issuable_display_type(@merge_request)
.btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-dropdown.gl-md-w-auto.gl-w-full
- = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret gl-display-none! gl-md-display-inline-flex!", title: _('Merge request actions'), 'aria-label': _('Merge request actions'), data: { toggle: 'dropdown', testid: 'merge-request-actions' } do
+ %span.js-sidebar-header-popover
+ = button_tag type: 'button', id: "new-actions-header-dropdown", class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret gl-display-none! gl-md-display-inline-flex!", title: _('Merge request actions'), 'aria-label': _('Merge request actions'), data: { toggle: 'dropdown', testid: 'merge-request-actions' } do
= sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon"
= button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do
%span.gl-dropdown-button-text= _('Merge request actions')
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 547f12ac8fc..7f2511d3e28 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -44,10 +44,10 @@
= render Pajamas::ButtonComponent.new(button_options: { class: "js-unsubscribe-button #{'hidden' if status.unsubscribed?}", data: { url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title }) do
= _('Unsubscribe')
.dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) }
- = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-w-full', data: { toggle: 'dropdown' } }) do
+ = render Pajamas::ButtonComponent.new(button_options: { data: { toggle: 'dropdown' } }) do
= _('Subscribe')
= sprite_icon('chevron-down')
- .dropdown-menu.dropdown-open-left
+ .dropdown-menu.dropdown-menu-right
%ul
%li
= render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button #{'hidden' unless status.unsubscribed?}", data: { status: status, url: toggle_subscription_project_label_path(@project, label) } }) do
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index f54354674e2..82e95a6a8e8 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -9,14 +9,15 @@
- reviewers = local_assigns.fetch(:reviewers, nil)
- in_group_context_with_iterations = @project.group.present? && issuable_sidebar[:supports_iterations]
- is_merge_request = issuable_type === 'merge_request'
-- moved_sidebar_enabled = moved_mr_sidebar_enabled? && is_merge_request
+- moved_sidebar_enabled = moved_mr_sidebar_enabled?
+- is_merge_request_with_flag = is_merge_request && moved_sidebar_enabled
-%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class} #{'right-sidebar-merge-requests' if moved_sidebar_enabled}", 'aria-live' => 'polite', 'aria-label': issuable_type }
- .issuable-sidebar{ class: "#{'is-merge-request' if moved_sidebar_enabled}" }
- .issuable-sidebar-header{ class: "#{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if moved_sidebar_enabled}" }
+%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type }
+ .issuable-sidebar{ class: "#{'is-merge-request' if is_merge_request_with_flag}" }
+ .issuable-sidebar-header{ class: "#{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if is_merge_request_with_flag}" }
%button.btn.gl-button.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ type: "reset", class: "gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled}", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
= sidebar_gutter_toggle_icon
- - if signed_in && !moved_sidebar_enabled
+ - if signed_in && !is_merge_request_with_flag
.js-sidebar-todo-widget-root{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } }
= form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
@@ -81,17 +82,17 @@
.js-sidebar-participants-widget-root
- .block.with-sub-blocks
- - if !moved_sidebar_enabled
+ - if !moved_sidebar_enabled
+ .block.with-sub-blocks
.js-sidebar-reference-widget-root
- - if issuable_type == 'merge_request' && !moved_sidebar_enabled
- .sub-block.js-sidebar-source-branch
- .sidebar-collapsed-icon.js-dont-change-state
- = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy')
- .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed
- %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap
- = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<span class='gl-font-monospace' data-testid='ref-name' title='#{html_escape(source_branch)}'>".html_safe, source_branch_close: "</span>".html_safe, source_branch: html_escape(source_branch) }
- = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy')
+ - if is_merge_request && !moved_sidebar_enabled
+ .sub-block.js-sidebar-source-branch
+ .sidebar-collapsed-icon.js-dont-change-state
+ = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy')
+ .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed
+ %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap
+ = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<span class='gl-font-monospace' data-testid='ref-name' title='#{html_escape(source_branch)}'>".html_safe, source_branch_close: "</span>".html_safe, source_branch: html_escape(source_branch) }
+ = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy')
- if show_forwarding_email
.block
diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml
index 9f7ed6b17c3..b6c0b73a83d 100644
--- a/app/views/shared/issue_type/_details_header.html.haml
+++ b/app/views/shared/issue_type/_details_header.html.haml
@@ -19,4 +19,4 @@
%a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
= sprite_icon('chevron-double-lg-left')
- .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user) }
+ .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user, @issuable_sidebar) }
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index cc1965945ac..5477b9395ea 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -1,7 +1,7 @@
- affix_offset = local_assigns.fetch(:affix_offset, "50")
- project = local_assigns[:project]
-%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite', 'aria-label': _('Milestone') }
+%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class(false), 'aria-live' => 'polite', 'aria-label': _('Milestone') }
.issuable-sidebar.milestone-sidebar
.block.milestone-progress.issuable-sidebar-header
%a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => s_('MilestoneSidebar|Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
diff --git a/config/feature_flags/development/super_sidebar_peek.yml b/config/feature_flags/development/super_sidebar_peek.yml
new file mode 100644
index 00000000000..d48daa067fc
--- /dev/null
+++ b/config/feature_flags/development/super_sidebar_peek.yml
@@ -0,0 +1,8 @@
+---
+name: super_sidebar_peek
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/116914
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/404966
+milestone: '15.11'
+type: development
+group: group::foundations
+default_enabled: false
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index f0ad6e85c9a..6194226fec8 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -413,6 +413,8 @@
- 3
- - product_analytics_initialize_analytics
- 1
+- - product_analytics_initialize_snowplow_product_analytics
+ - 1
- - project_cache
- 1
- - project_destroy
diff --git a/db/docs/batched_background_migrations/update_code_suggestions_for_namespace_settings.yml b/db/docs/batched_background_migrations/update_code_suggestions_for_namespace_settings.yml
new file mode 100644
index 00000000000..e0196878d61
--- /dev/null
+++ b/db/docs/batched_background_migrations/update_code_suggestions_for_namespace_settings.yml
@@ -0,0 +1,6 @@
+---
+migration_job_name: UpdateCodeSuggestionsForNamespaceSettings
+description: Enables Code Suggestions for all namespaces by default
+feature_category: code_suggestions
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/117899
+milestone: 16.0
diff --git a/db/migrate/20230406095544_add_pa_configurator_base_to_app_settings.rb b/db/migrate/20230406095544_add_pa_configurator_base_to_app_settings.rb
new file mode 100644
index 00000000000..b6b33cdf17b
--- /dev/null
+++ b/db/migrate/20230406095544_add_pa_configurator_base_to_app_settings.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AddPaConfiguratorBaseToAppSettings < Gitlab::Database::Migration[2.1]
+ def up
+ add_column :application_settings, :encrypted_product_analytics_configurator_connection_string, :binary
+ add_column :application_settings, :encrypted_product_analytics_configurator_connection_string_iv, :binary
+ end
+
+ def down
+ remove_column :application_settings, :encrypted_product_analytics_configurator_connection_string
+ remove_column :application_settings, :encrypted_product_analytics_configurator_connection_string_iv
+ end
+end
diff --git a/db/migrate/20230410092450_add_product_analytics_instrumentation_key_to_project_settings.rb b/db/migrate/20230410092450_add_product_analytics_instrumentation_key_to_project_settings.rb
new file mode 100644
index 00000000000..eaaddef083c
--- /dev/null
+++ b/db/migrate/20230410092450_add_product_analytics_instrumentation_key_to_project_settings.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class AddProductAnalyticsInstrumentationKeyToProjectSettings < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ # rubocop:disable Migration/AddLimitToTextColumns
+ # limit is added in 20230413153140_add_text_limit_to_project_settings_instrumentation_key.rb
+ def up
+ with_lock_retries do
+ add_column :project_settings, :product_analytics_instrumentation_key, :text unless
+ column_exists?(:project_settings, :product_analytics_instrumentation_key)
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_column :project_settings, :product_analytics_instrumentation_key
+ end
+ end
+ # rubocop:enable Migration/AddLimitToTextColumns
+end
diff --git a/db/migrate/20230413153140_add_text_limit_to_project_settings_instrumentation_key.rb b/db/migrate/20230413153140_add_text_limit_to_project_settings_instrumentation_key.rb
new file mode 100644
index 00000000000..7ec0a800066
--- /dev/null
+++ b/db/migrate/20230413153140_add_text_limit_to_project_settings_instrumentation_key.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AddTextLimitToProjectSettingsInstrumentationKey < Gitlab::Database::Migration[2.1]
+ disable_ddl_transaction!
+
+ def up
+ add_text_limit :project_settings, :product_analytics_instrumentation_key, 255
+ end
+
+ def down
+ remove_text_limit :project_settings, :product_analytics_instrumentation_key
+ end
+end
diff --git a/db/post_migrate/20230405132104_remove_saml_provider_and_identities_non_root_group.rb b/db/post_migrate/20230405132104_remove_saml_provider_and_identities_non_root_group.rb
new file mode 100644
index 00000000000..55a017464c2
--- /dev/null
+++ b/db/post_migrate/20230405132104_remove_saml_provider_and_identities_non_root_group.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class RemoveSamlProviderAndIdentitiesNonRootGroup < Gitlab::Database::Migration[2.1]
+ BATCH_SIZE = 500
+
+ disable_ddl_transaction!
+ restrict_gitlab_migration gitlab_schema: :gitlab_main
+
+ def up
+ each_batch_range('saml_providers', scope: ->(table) { table.all }, of: BATCH_SIZE) do |min, max|
+ execute <<~SQL
+ DELETE FROM identities
+ WHERE identities.saml_provider_id
+ IN
+ (
+ SELECT saml_providers.id FROM saml_providers
+ INNER JOIN namespaces ON namespaces.id=saml_providers.group_id
+ AND namespaces.type='Group' AND namespaces.parent_id IS NOT NULL
+ AND saml_providers.id BETWEEN #{min} AND #{max}
+ );
+
+ DELETE FROM saml_providers
+ USING namespaces
+ WHERE namespaces.id=saml_providers.group_id
+ AND namespaces.type='Group' AND namespaces.parent_id IS NOT NULL
+ AND saml_providers.id BETWEEN #{min} AND #{max};
+ SQL
+ end
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/post_migrate/20230405132855_remove_scim_token_and_scim_identity_non_root_group.rb b/db/post_migrate/20230405132855_remove_scim_token_and_scim_identity_non_root_group.rb
new file mode 100644
index 00000000000..aa149acc5be
--- /dev/null
+++ b/db/post_migrate/20230405132855_remove_scim_token_and_scim_identity_non_root_group.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class RemoveScimTokenAndScimIdentityNonRootGroup < Gitlab::Database::Migration[2.1]
+ BATCH_SIZE = 500
+
+ disable_ddl_transaction!
+ restrict_gitlab_migration gitlab_schema: :gitlab_main
+
+ def up
+ each_batch_range('scim_oauth_access_tokens', scope: ->(table) { table.all }, of: BATCH_SIZE) do |min, max|
+ execute <<~SQL
+ DELETE FROM scim_identities
+ WHERE scim_identities.group_id
+ IN
+ (
+ SELECT namespaces.id FROM scim_oauth_access_tokens
+ INNER JOIN namespaces ON namespaces.id=scim_oauth_access_tokens.group_id
+ WHERE namespaces.type='Group' AND namespaces.parent_id IS NOT NULL
+ AND scim_oauth_access_tokens.id BETWEEN #{min} AND #{max}
+ );
+
+ DELETE FROM scim_oauth_access_tokens
+ USING namespaces
+ WHERE namespaces.id=scim_oauth_access_tokens.group_id
+ AND namespaces.type='Group' AND namespaces.parent_id IS NOT NULL
+ AND scim_oauth_access_tokens.id BETWEEN #{min} AND #{max};
+ SQL
+ end
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/post_migrate/20230418164957_queue_update_code_suggestions_for_namespace_settings.rb b/db/post_migrate/20230418164957_queue_update_code_suggestions_for_namespace_settings.rb
new file mode 100644
index 00000000000..faf5a4978ab
--- /dev/null
+++ b/db/post_migrate/20230418164957_queue_update_code_suggestions_for_namespace_settings.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class QueueUpdateCodeSuggestionsForNamespaceSettings < Gitlab::Database::Migration[2.1]
+ MIGRATION = 'UpdateCodeSuggestionsForNamespaceSettings'
+ DELAY_INTERVAL = 2.minutes
+ BATCH_SIZE = 1_000
+
+ disable_ddl_transaction!
+
+ restrict_gitlab_migration gitlab_schema: :gitlab_main
+
+ def up
+ queue_batched_background_migration(
+ MIGRATION,
+ :namespace_settings,
+ :namespace_id,
+ job_interval: DELAY_INTERVAL,
+ batch_size: BATCH_SIZE
+ )
+ end
+
+ def down
+ delete_batched_background_migration(MIGRATION, :namespace_settings, :namespace_id, [])
+ end
+end
diff --git a/db/schema_migrations/20230405132104 b/db/schema_migrations/20230405132104
new file mode 100644
index 00000000000..e8d9bd7ded7
--- /dev/null
+++ b/db/schema_migrations/20230405132104
@@ -0,0 +1 @@
+eae464c7583b909d975c379d196b7ae5301580f7195907a476ca1a146d8cb6b1 \ No newline at end of file
diff --git a/db/schema_migrations/20230405132855 b/db/schema_migrations/20230405132855
new file mode 100644
index 00000000000..209578a8ed4
--- /dev/null
+++ b/db/schema_migrations/20230405132855
@@ -0,0 +1 @@
+a7928284883d79b1204bb39a2a2d34b173771ce6dc484cefdb1c7ec3e9e9477a \ No newline at end of file
diff --git a/db/schema_migrations/20230406095544 b/db/schema_migrations/20230406095544
new file mode 100644
index 00000000000..56e891d65db
--- /dev/null
+++ b/db/schema_migrations/20230406095544
@@ -0,0 +1 @@
+3774f65f475364f3748502b035bdb3d18db0588a0f3ed45ca19cc5492ce754df \ No newline at end of file
diff --git a/db/schema_migrations/20230410092450 b/db/schema_migrations/20230410092450
new file mode 100644
index 00000000000..1b7b88026f5
--- /dev/null
+++ b/db/schema_migrations/20230410092450
@@ -0,0 +1 @@
+3a2c45579f8f566a2224afcab84b9f403e49379603e164f141a4dc89b5f4b512 \ No newline at end of file
diff --git a/db/schema_migrations/20230413153140 b/db/schema_migrations/20230413153140
new file mode 100644
index 00000000000..97af4a6962f
--- /dev/null
+++ b/db/schema_migrations/20230413153140
@@ -0,0 +1 @@
+e78e74c9c068235ede55859b56bea671d9da5c3e74ac315e1b9391b6eef2649b \ No newline at end of file
diff --git a/db/schema_migrations/20230418164957 b/db/schema_migrations/20230418164957
new file mode 100644
index 00000000000..e7fa549aba4
--- /dev/null
+++ b/db/schema_migrations/20230418164957
@@ -0,0 +1 @@
+80eb5db3db246ff6b2b857252cee05049cc0f7c09d0487175cfa90eeaf93f20c \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index b08065a5cd4..87c6aff9937 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -11800,6 +11800,8 @@ CREATE TABLE application_settings (
encrypted_openai_api_key bytea,
encrypted_openai_api_key_iv bytea,
database_max_running_batched_background_migrations integer DEFAULT 2 NOT NULL,
+ encrypted_product_analytics_configurator_connection_string bytea,
+ encrypted_product_analytics_configurator_connection_string_iv bytea,
silent_mode_enabled boolean DEFAULT false NOT NULL,
package_metadata_purl_types smallint[] DEFAULT '{}'::smallint[],
CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)),
@@ -21107,11 +21109,13 @@ CREATE TABLE project_settings (
pages_unique_domain_enabled boolean DEFAULT false NOT NULL,
pages_unique_domain text,
runner_registration_enabled boolean DEFAULT true,
+ product_analytics_instrumentation_key text,
CONSTRAINT check_1a30456322 CHECK ((char_length(pages_unique_domain) <= 63)),
CONSTRAINT check_2981f15877 CHECK ((char_length(jitsu_key) <= 100)),
CONSTRAINT check_3a03e7557a CHECK ((char_length(previous_default_branch) <= 4096)),
CONSTRAINT check_3ca5cbffe6 CHECK ((char_length(issue_branch_template) <= 255)),
CONSTRAINT check_67292e4b99 CHECK ((char_length(mirror_branch_regex) <= 255)),
+ CONSTRAINT check_acb7fad2f9 CHECK ((char_length(product_analytics_instrumentation_key) <= 255)),
CONSTRAINT check_b09644994b CHECK ((char_length(squash_commit_template) <= 500)),
CONSTRAINT check_bde223416c CHECK ((show_default_award_emojis IS NOT NULL)),
CONSTRAINT check_eaf7cfb6a7 CHECK ((char_length(merge_commit_template) <= 500))
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 170fefa973e..919f72f6b3d 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -12462,6 +12462,15 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="commitpipelinesupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Pipelines updated before this date. |
| <a id="commitpipelinesusername"></a>`username` | [`String`](#string) | Filter pipelines by the user that triggered the pipeline. |
+### `CommitParentNames`
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="commitparentnamesnames"></a>`names` | [`[String!]`](#string) | Names of the commit parent (branch or tag). |
+| <a id="commitparentnamestotalcount"></a>`totalCount` | [`Int`](#int) | Total of parent branches or tags. |
+
### `ComplianceFramework`
Represents a ComplianceFramework associated with a Project.
@@ -18711,6 +18720,19 @@ four standard [pagination arguments](#connection-pagination-arguments):
| ---- | ---- | ----------- |
| <a id="projectboardsid"></a>`id` | [`BoardID`](#boardid) | Find a board by its ID. |
+##### `Project.branchesTippingAtCommit`
+
+Get branch names tipping at a given commit.
+
+Returns [`CommitParentNames`](#commitparentnames).
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="projectbranchestippingatcommitcommitsha"></a>`commitSha` | [`String!`](#string) | Project commit SHA identifier. For example, `287774414568010855642518513f085491644061`. |
+| <a id="projectbranchestippingatcommitlimit"></a>`limit` | [`Int`](#int) | Number of branch names to return. |
+
##### `Project.ciConfigVariables`
CI/CD config variable.
@@ -19722,6 +19744,19 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="projectsnippetsids"></a>`ids` | [`[SnippetID!]`](#snippetid) | Array of global snippet IDs. For example, `gid://gitlab/ProjectSnippet/1`. |
| <a id="projectsnippetsvisibility"></a>`visibility` | [`VisibilityScopesEnum`](#visibilityscopesenum) | Visibility of the snippet. |
+##### `Project.tagsTippingAtCommit`
+
+Get tag names tipping at a given commit.
+
+Returns [`CommitParentNames`](#commitparentnames).
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="projecttagstippingatcommitcommitsha"></a>`commitSha` | [`String!`](#string) | Project commit SHA identifier. For example, `287774414568010855642518513f085491644061`. |
+| <a id="projecttagstippingatcommitlimit"></a>`limit` | [`Int`](#int) | Number of branch names to return. |
+
##### `Project.terraformState`
Find a single Terraform state by name.
diff --git a/doc/architecture/blueprints/clickhouse_usage/index.md b/doc/architecture/blueprints/clickhouse_usage/index.md
index 610ff81f571..8a5530313e5 100644
--- a/doc/architecture/blueprints/clickhouse_usage/index.md
+++ b/doc/architecture/blueprints/clickhouse_usage/index.md
@@ -2,7 +2,7 @@
status: proposed
creation-date: "2023-02-02"
authors: [ "@nhxnguyen" ]
-coach: "@grzesiek"
+coach: "@grzesiek"
approvers: [ "@dorrino", "@nhxnguyen" ]
owning-stage: "~devops::data_stores"
participating-stages: ["~section::ops", "~section::dev"]
@@ -52,3 +52,7 @@ Note that we are still formulating proposals and will update the blueprint accor
## Best Practices
Best practices and guidelines for developing performant and scalable features using ClickHouse are located in the [ClickHouse developer documentation](../../../development/database/clickhouse/index.md).
+
+## Cost and maintenance analysis
+
+ClickHouse components cost and maintenance analysis is located in the [ClickHouse Self-Managed component costs and maintenance requirements](self_managed_costs_and_requirements/index.md).
diff --git a/doc/architecture/blueprints/clickhouse_usage/self_managed_costs_and_requirements/index.md b/doc/architecture/blueprints/clickhouse_usage/self_managed_costs_and_requirements/index.md
new file mode 100644
index 00000000000..d8c9c0b25d5
--- /dev/null
+++ b/doc/architecture/blueprints/clickhouse_usage/self_managed_costs_and_requirements/index.md
@@ -0,0 +1,65 @@
+---
+status: proposed
+creation-date: "2023-04-04"
+authors: [ "@niskhakova", "@dmakovey" ]
+coach: "@grzesiek"
+approvers: [ "@dorrino", "@nhxnguyen" ]
+owning-stage: "~workinggroup::clickhouse"
+participating-stages: ["~section::enablement"]
+---
+
+# ClickHouse Self-Managed component costs and maintenance requirements
+
+## Summary
+
+[ClickHouse](https://clickhouse.com/) requires additional cost and maintenance for self-managed customers:
+
+- **Resource allocation cost**: ClickHouse requires a considerable amount of resources to run optimally.
+ - [Minimum cost estimation](#minimum-self-managed-component-costs) shows that setting up ClickHouse can be applicable only for very large Reference Architectures: 25k and up.
+- **High availability**: ClickHouse SaaS supports HA. No documented HA configuration for self-managed at the moment.
+- **Geo setups**: Sync and replication complexity for GitLab Geo setups.
+- **Upgrades**: An additional database to maintain and upgrade along with existing Postgres database. This also includes compatibility issues of mapping GitLab version to ClickHouse version and keeping them up-to-date.
+- **Backup and restore:** Self-managed customers need to have an engineer who is familiar with backup strategies and disaster recovery process in ClickHouse or switch to ClickHouse SaaS.
+- **Monitoring**: ClickHouse can use Prometheus, additional component to monitor and troubleshoot.
+- **Limitations**: Azure object storage is not supported. GitLab does not have the documentation or support expertise to assist customers with deployment and operation of self-managed ClickHouse.
+- **ClickHouse SaaS**: Customers using a self-managed GitLab instance with regulatory or compliance requirements, or latency concerns likely cannot use ClickHouse SaaS.
+
+### Minimum self-managed component costs
+
+Based on [ClickHouse spec requirements](https://gitlab.com/gitlab-com/www-gitlab-com/-/issues/14384#note_1307456092) analysis
+and collaborating with ClickHouse team, we identified the following minimal configurations for ClickHouse self-managed:
+
+1. ClickHouse High Availability (HA)
+ - ClickHouse - 2 machines with >=16-cores, >=64 GB RAM, SSD, 10 GB Internet. Each machine also runs Keeper.
+ - [Keeper](https://clickhouse.com/docs/en/guides/sre/keeper/clickhouse-keeper) - 1 machine with 2 CPU, 4 GB of RAM, SSD with high IOPS
+1. ClickHouse non-HA
+ - ClickHouse - 1 machine with >=16-cores, >=64 GB RAM, SSD, 10 GB Internet.
+
+The following [cost table](https://gitlab.com/gitlab-com/www-gitlab-com/-/issues/14384#note_1324085466) was compiled using the machine CPU and memory requirements for ClickHouse, and comparing them to the
+GitLab Reference Architecture sizes and [costs](../../../../administration/reference_architectures/index.md#cost-to-run) from the GCP calculator.
+
+| Reference Architecture | ClickHouse type | ClickHouse cost / (GitLab cost + ClickHouse cost) |
+|-------------|-----------------|-----------------------------------|
+| [1k - non HA](https://cloud.google.com/products/calculator#id=a6d6a94a-c7dc-4c22-85c4-7c5747f272ed) | [non-HA](https://cloud.google.com/products/calculator#id=9af5359e-b155-451c-b090-5f0879bb591e) | 78.01% |
+| [2k - non HA](https://cloud.google.com/products/calculator#id=0d3aff1f-ea3d-43f9-aa59-df49d27c35ca) | [non-HA](https://cloud.google.com/products/calculator#id=9af5359e-b155-451c-b090-5f0879bb591e) | 44.50% |
+| [3k - HA](https://cloud.google.com/products/calculator/#id=15fc2bd9-5b1c-479d-bc46-d5ce096b8107) | [HA](https://cloud.google.com/products/calculator#id=9909f5af-d41a-4da2-b8cc-a0347702a823) | 37.87% |
+| [5k - HA](https://cloud.google.com/products/calculator/#id=9a798136-53f2-4c35-be43-8e1e975a6663) | [HA](https://cloud.google.com/products/calculator#id=9909f5af-d41a-4da2-b8cc-a0347702a823) | 30.92% |
+| [10k - HA](https://cloud.google.com/products/calculator#id=cbe61840-31a1-487f-88fa-631251c2fde5) | [HA](https://cloud.google.com/products/calculator#id=9909f5af-d41a-4da2-b8cc-a0347702a823) | 20.47% |
+| [25k - HA](https://cloud.google.com/products/calculator#id=b4b8b587-508a-4433-adc8-dc506bbe924f) | [HA](https://cloud.google.com/products/calculator#id=9909f5af-d41a-4da2-b8cc-a0347702a823) | 14.30% |
+| [50k - HA](https://cloud.google.com/products/calculator/#id=48b4d817-d6cd-44b8-b069-0ba9a5d123ea) | [HA](https://cloud.google.com/products/calculator#id=9909f5af-d41a-4da2-b8cc-a0347702a823) | 8.16% |
+
+NOTE:
+The ClickHouse Self-Managed component evaluation is the minimum estimation for the costs
+with a simplified architecture.
+
+The following components increase the cost, and were not considered in the minimum calculation:
+
+- Disk size - depends on data size, hard to estimate.
+- Disk types - ClickHouse recommends [fast SSDs](https://clickhouse.com/docs/ru/operations/tips#storage-subsystem).
+- Network usage - ClickHouse recommends using [10 GB network, if possible](https://clickhouse.com/docs/en/operations/tips#network).
+- For HA we sum minimum cost across all reference architectures from 3k to 50k users, but HA specs tend to increase with user count.
+
+### Resources
+
+- [Research and understand component costs and maintenance requirements of running a ClickHouse instance with GitLab](https://gitlab.com/gitlab-com/www-gitlab-com/-/issues/14384)
+- [ClickHouse for Error Tracking on GitLab.com](https://gitlab.com/gitlab-com/gl-infra/readiness/-/blob/master/library/database/clickhouse/index.md)
diff --git a/doc/development/integrations/index.md b/doc/development/integrations/index.md
index 0b45e83bb26..efe70e5948a 100644
--- a/doc/development/integrations/index.md
+++ b/doc/development/integrations/index.md
@@ -236,9 +236,7 @@ module Integrations
end
```
-### Expose the integration in the API
-
-#### REST API
+### Expose the integration in the REST API
To expose the integration in the [REST API](../../api/integrations.md):
@@ -257,46 +255,6 @@ Sensitive fields are not exposed over the API. Sensitive fields are those fields
- `token`
- `webhook`
-#### GraphQL API
-
-Integrations use the `Types::Projects::ServiceType` type by default,
-which only exposes the `type` and `active` properties.
-
-To expose additional properties, you can write a class implementing `ServiceType`:
-
-```ruby
-# in app/graphql/types/project/services/foo_bar_service_type.rb
-module Types
- module Projects
- module Services
- class FooBarServiceType < BaseObject
- graphql_name 'FooBarService'
- implements(Types::Projects::ServiceType)
- authorize :read_project
-
- field :frobinity,
- GraphQL::Types::Float,
- null: true,
- description: 'The level of frobinity.'
-
- field :foo_label,
- GraphQL::Types::String,
- null: true,
- description: 'The foo label to apply.'
- end
- end
- end
-end
-```
-
-Each property you want to expose should have a field defined for it. You can also expose any public instance method of the integration.
-
-Contact a member of the Integrations team to discuss the best authorization.
-
-Reference documentation for GraphQL is automatically generated.
-
-You can also refer to our [GraphQL API style guide](../api_graphql_styleguide.md).
-
## Availability of integrations
By default, integrations are available on the project, group, and instance level.
diff --git a/doc/operations/incident_management/manage_incidents.md b/doc/operations/incident_management/manage_incidents.md
index 338dacda166..9d0c8075ff9 100644
--- a/doc/operations/incident_management/manage_incidents.md
+++ b/doc/operations/incident_management/manage_incidents.md
@@ -226,6 +226,10 @@ When you close an incident that is linked to an [alert](alerts.md),
the linked alert's status changes to **Resolved**.
You are then credited with the alert's status change.
+<!-- Delete when the `moved_mr_sidebar` feature flag is removed -->
+If you don't see this action at the top of an incident, your project or instance might have
+enabled a feature flag for [moved actions](../../user/project/merge_requests/index.md#move-sidebar-actions)
+
### Automatically close incidents via recovery alerts
> [Introduced for HTTP integrations](https://gitlab.com/gitlab-org/gitlab/-/issues/13402) in GitLab 13.4.
@@ -249,6 +253,22 @@ When GitLab receives a recovery alert, it closes the associated incident.
This action is recorded as a system note on the incident indicating that it
was closed automatically by the GitLab Alert bot.
+## Delete an incident
+
+Prerequisites:
+
+- You must have the Owner role for a project.
+
+To delete an incident:
+
+1. In an incident, select **Incident actions** (**{ellipsis_v}**).
+1. Select **Delete incident**.
+
+Alternatively:
+
+1. In an incident, select **Edit title and description** (**{pencil}**).
+1. Select **Delete incident**.
+
## Other actions
Because incidents in GitLab are built on top of [issues](../../user/project/issues/index.md),
diff --git a/doc/security/rate_limits.md b/doc/security/rate_limits.md
index a9ccbccaa90..108510ae7c2 100644
--- a/doc/security/rate_limits.md
+++ b/doc/security/rate_limits.md
@@ -148,6 +148,14 @@ There is a rate limit for the endpoint `project/:id/jobs`, which is enforced to
The **rate limit** is 600 calls per minute per authenticated user.
+### AI action
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118010) in GitLab 16.0.
+
+There is a rate limit for the GraphQL `aiAction` mutation, which is enforced to prevent from abusing this endpoint.
+
+The **rate limit** is 20 calls per hour per authenticated user.
+
## Troubleshooting
### Rack Attack is denylisting the load balancer
diff --git a/doc/user/group/epics/img/button_close_epic.png b/doc/user/group/epics/img/button_close_epic.png
deleted file mode 100644
index aa1a889ea23..00000000000
--- a/doc/user/group/epics/img/button_close_epic.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/group/epics/manage_epics.md b/doc/user/group/epics/manage_epics.md
index 0dc87b7e4e4..1349d28c6a1 100644
--- a/doc/user/group/epics/manage_epics.md
+++ b/doc/user/group/epics/manage_epics.md
@@ -159,14 +159,13 @@ Prerequisites:
- You must have at least the Reporter role for the epic's group.
-Whenever you decide that there is no longer need for that epic,
-close the epic by:
+To close an epic, at the top of an epic, select **Close epic**.
-- Selecting **Close epic**.
+<!-- Delete when the `moved_mr_sidebar` feature flag is removed -->
+If you don't see this action at the top of an epic, your project or instance might have
+enabled a feature flag for [moved actions](../../project/merge_requests/index.md#move-sidebar-actions)
- ![close epic - button](img/button_close_epic.png)
-
-- Using the `/close` [quick action](../../project/quick_actions.md).
+You can also use the `/close` [quick action](../../project/quick_actions.md).
## Reopen a closed epic
diff --git a/doc/user/product_analytics/index.md b/doc/user/product_analytics/index.md
index 871971d8a58..4c5733ed5ec 100644
--- a/doc/user/product_analytics/index.md
+++ b/doc/user/product_analytics/index.md
@@ -75,16 +75,17 @@ Prerequisite:
1. Select **Enable product analytics** and enter the configuration values.
The following table shows the required configuration parameters and example values:
- | Name | Value |
- |------------------------------|------------------------------------------------------------|
- | Jitsu host | `https://jitsu.gitlab.com` |
- | Jitsu project ID | `g0maofw84gx5sjxgse2k` |
- | Jitsu administrator email | `jitsu.admin@gitlab.com` |
- | Jitsu administrator password | `<your_password>` |
- | Collector host | `https://collector.gitlab.com` |
- | Clickhouse URL | `https://<username>:<password>@clickhouse.gitlab.com:8123` |
- | Cube API URL | `https://cube.gitlab.com` |
- | Cube API key | `25718201b3e9...ae6bbdc62dbb` |
+ | Name | Value |
+ |--------------------------------|------------------------------------------------------------|
+ | Configurator connection string | `https://test:test@configurator.gitlab.com` |
+ | Jitsu host | `https://jitsu.gitlab.com` |
+ | Jitsu project ID | `g0maofw84gx5sjxgse2k` |
+ | Jitsu administrator email | `jitsu.admin@gitlab.com` |
+ | Jitsu administrator password | `<your_password>` |
+ | Collector host | `https://collector.gitlab.com` |
+ | ClickHouse URL | `https://<username>:<password>@clickhouse.gitlab.com:8123` |
+ | Cube API URL | `https://cube.gitlab.com` |
+ | Cube API key | `25718201b3e9...ae6bbdc62dbb` |
1. Select **Save changes**.
diff --git a/doc/user/profile/comment_templates.md b/doc/user/profile/comment_templates.md
index 527a57ae5da..b9cd595916c 100644
--- a/doc/user/profile/comment_templates.md
+++ b/doc/user/profile/comment_templates.md
@@ -7,7 +7,12 @@ type: howto
# Comment templates **(FREE)**
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/352956) in GitLab 14.9 [with a flag](../../administration/feature_flags.md) named `saved_replies`. Disabled by default.
+> - GraphQL support [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/352956) in GitLab 14.9 [with a flag](../../administration/feature_flags.md) named `saved_replies`. Disabled by default.
+> - User interface [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113232) in GitLab 15.10 [with a flag](../../administration/feature_flags.md) named `saved_replies`. Disabled by default. Enabled for GitLab team members only.
+
+FLAG:
+On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `saved_replies`.
+On GitLab.com, this feature is not available by default, but enabled for GitLab team members.
With comment templates, create and reuse text for any text area in:
@@ -21,6 +26,8 @@ or large, like chunks of boilerplate text you use frequently:
![Comment templates dropdown list](img/saved_replies_dropdown_v15_10.png)
+For more information about the rollout plan for this feature, see [issue 352956](https://gitlab.com/gitlab-org/gitlab/-/issues/352956).
+
## Use comment templates in a text area
To include the text of a comment template in your comment:
diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md
index c0c967a3f18..89366c73b16 100644
--- a/doc/user/profile/notifications.md
+++ b/doc/user/profile/notifications.md
@@ -239,7 +239,7 @@ Turning this toggle off only unsubscribes you from updates related to this issue
Learn how to [opt out of all emails from GitLab](#opt-out-of-all-gitlab-emails).
<!-- Delete when the `moved_mr_sidebar` feature flag is removed -->
-If you don't see this action on the right sidebar, your project or instance may have
+If you don't see this action on the right sidebar, your project or instance might have
enabled a feature flag for [moved sidebar actions](../project/merge_requests/index.md#move-sidebar-actions).
### Notification events on issues, merge requests, and epics
diff --git a/doc/user/project/issues/create_issues.md b/doc/user/project/issues/create_issues.md
index b6931149ede..4511c89b0ff 100644
--- a/doc/user/project/issues/create_issues.md
+++ b/doc/user/project/issues/create_issues.md
@@ -78,7 +78,7 @@ Prerequisites:
To create an issue from another issue:
-1. In an existing issue, select the vertical ellipsis (**{ellipsis_v}**).
+1. In an existing issue, select **Issue actions** (**{ellipsis_v}**).
1. Select **New related issue**.
1. Complete the [fields](#fields-in-the-new-issue-form).
The new issue form has a **Relate to issue #123** checkbox, where `123` is the ID of the
diff --git a/doc/user/project/issues/managing_issues.md b/doc/user/project/issues/managing_issues.md
index 069bc4582c6..b532fd0c5b8 100644
--- a/doc/user/project/issues/managing_issues.md
+++ b/doc/user/project/issues/managing_issues.md
@@ -209,6 +209,10 @@ To close an issue, you can do the following:
- At the top of the issue, select **Close issue**.
- In an [issue board](../issue_board.md), drag an issue card from its list into the **Closed** list.
+<!-- Delete when the `moved_mr_sidebar` feature flag is removed -->
+If you don't see this action at the top of an issue, your project or instance might have
+enabled a feature flag for [moved actions](../merge_requests/index.md#move-sidebar-actions).
+
### Reopen a closed issue
Prerequisites:
@@ -344,7 +348,7 @@ Prerequisites:
To delete an issue:
-1. In an issue, select the vertical ellipsis (**{ellipsis_v}**).
+1. In an issue, select **Issue actions** (**{ellipsis_v}**).
1. Select **Delete issue**.
Alternatively:
@@ -362,7 +366,7 @@ You can promote an issue to an [epic](../../group/epics/index.md) in the immedia
To promote an issue to an epic:
-1. In an issue, select the vertical ellipsis (**{ellipsis_v}**).
+1. In an issue, select **Issue actions** (**{ellipsis_v}**).
1. Select **Promote to epic**.
Alternatively, you can use the `/promote` [quick action](../quick_actions.md#issues-merge-requests-and-epics).
@@ -472,6 +476,10 @@ You can now paste the reference into another description or comment.
Read more about issue references in [GitLab-Flavored Markdown](../../markdown.md#gitlab-specific-references).
+<!-- Delete when the `moved_mr_sidebar` feature flag is removed -->
+If you don't see this action on the right sidebar, your project or instance might have
+enabled a feature flag for [moved actions](../merge_requests/index.md#move-sidebar-actions).
+
## Copy issue email address
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/18816) in GitLab 13.8.
diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md
index ee7f4e5dfed..6bcf3f15685 100644
--- a/doc/user/project/merge_requests/index.md
+++ b/doc/user/project/merge_requests/index.md
@@ -251,7 +251,8 @@ after merging does not retarget open merge requests. This improvement is
<!-- When the `moved_mr_sidebar` feature flag is removed, delete this topic and update the steps for these actions
like in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87727/diffs?diff_id=522279685#5d9afba799c4af9920dab533571d7abb8b9e9163 -->
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85584) in GitLab 14.10 [with a flag](../../../administration/feature_flags.md) named `moved_mr_sidebar`. Disabled by default.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85584) in GitLab 14.10 [with a flag](../../../administration/feature_flags.md) named `moved_mr_sidebar`. Disabled by default.
+> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/373757) to also move actions on issues, incidents, and epics in GitLab 15.10.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available per project or for your entire instance, ask an administrator to [enable the feature flag](../../../administration/feature_flags.md) named `moved_mr_sidebar`.
@@ -266,6 +267,8 @@ When this feature flag is enabled, in the upper-right corner,
- [Lock discussion](../../discussions/index.md#prevent-comments-by-locking-the-discussion)
- Copy reference
+In GitLab 15.10 and later, similar action menus are available on issues, incidents, and epics.
+
When this feature flag is disabled, these actions are in the right sidebar.
## Merge request workflows
diff --git a/doc/user/report_abuse.md b/doc/user/report_abuse.md
index de2b82c28d3..c4b9af28220 100644
--- a/doc/user/report_abuse.md
+++ b/doc/user/report_abuse.md
@@ -50,7 +50,7 @@ A URL to the reported user's comment is pre-filled in the abuse report's
## Report abuse from an issue
-1. On the issue, in the upper-right corner, select the vertical ellipsis (**{ellipsis_v}**).
+1. On the issue, in the upper-right corner, select **Issue actions** (**{ellipsis_v}**).
1. Select **Report abuse to administrator**.
1. Select a reason for reporting the user.
1. Complete an abuse report.
@@ -58,7 +58,7 @@ A URL to the reported user's comment is pre-filled in the abuse report's
## Report abuse from a merge request
-1. On the merge request, in the upper-right corner, select the vertical ellipsis (**{ellipsis_v}**).
+1. On the merge request, in the upper-right corner, select **Merge request actions** (**{ellipsis_v}**).
1. Select **Report abuse to administrator**.
1. Select a reason for reporting this user.
1. Complete an abuse report.
diff --git a/lib/gitlab/application_rate_limiter.rb b/lib/gitlab/application_rate_limiter.rb
index 71629eb701c..5b5f69858d3 100644
--- a/lib/gitlab/application_rate_limiter.rb
+++ b/lib/gitlab/application_rate_limiter.rb
@@ -56,6 +56,7 @@ module Gitlab
namespace_exists: { threshold: 20, interval: 1.minute },
fetch_google_ip_list: { threshold: 10, interval: 1.minute },
project_fork_sync: { threshold: 10, interval: 30.minutes },
+ ai_action: { threshold: 20, interval: 1.hour },
jobs_index: { threshold: 600, interval: 1.minute },
bulk_import: { threshold: 6, interval: 1.minute },
projects_api_rate_limit_unauthenticated: {
diff --git a/lib/gitlab/background_migration/update_code_suggestions_for_namespace_settings.rb b/lib/gitlab/background_migration/update_code_suggestions_for_namespace_settings.rb
new file mode 100644
index 00000000000..ee3919e5fbc
--- /dev/null
+++ b/lib/gitlab/background_migration/update_code_suggestions_for_namespace_settings.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ # This class is used to update the code_suggestions column
+ # to true for the namespace_settings table.
+ class UpdateCodeSuggestionsForNamespaceSettings < BatchedMigrationJob
+ operation_name :update_code_suggestions_to_true
+ feature_category :code_suggestions
+
+ def perform
+ each_sub_batch do |sub_batch|
+ update_code_suggestions_to_true(sub_batch)
+ end
+ end
+
+ private
+
+ def update_code_suggestions_to_true(relation)
+ relation.update_all(code_suggestions: true)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index af3d13d0c79..eb071b44374 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -66,6 +66,7 @@ module Gitlab
push_frontend_feature_flag(:security_auto_fix)
push_frontend_feature_flag(:source_editor_toolbar)
push_frontend_feature_flag(:vscode_web_ide, current_user)
+ push_frontend_feature_flag(:super_sidebar_peek)
end
# Exposes the state of a feature flag to the frontend code.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 7204dddcb1b..ab0ae285087 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -754,6 +754,9 @@ msgstr ""
msgid "%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}."
msgstr ""
+msgid "%{issuableDisplayName} %{lockStatus}."
+msgstr ""
+
msgid "%{issuableType} will be removed! Are you sure?"
msgstr ""
@@ -1863,6 +1866,9 @@ msgstr ""
msgid "AI|Give feedback on code explanation"
msgstr ""
+msgid "AI|Responses generated by AI"
+msgstr ""
+
msgid "AI|Something went wrong. Please try again later"
msgstr ""
@@ -3129,6 +3135,9 @@ msgstr ""
msgid "AdminSettings|Preview payload"
msgstr ""
+msgid "AdminSettings|Product analytics configurator connection string"
+msgstr ""
+
msgid "AdminSettings|Project export"
msgstr ""
@@ -3216,6 +3225,9 @@ msgstr ""
msgid "AdminSettings|The URL of your Cube instance."
msgstr ""
+msgid "AdminSettings|The connection string of your product analytics configurator instance for Snowplow configuration."
+msgstr ""
+
msgid "AdminSettings|The default domain to use for Auto Review Apps and Auto Deploy stages in all projects."
msgstr ""
@@ -8818,6 +8830,9 @@ msgstr ""
msgid "Checking branch availability..."
msgstr ""
+msgid "Checking branch availability…"
+msgstr ""
+
msgid "Checking group path availability..."
msgstr ""
@@ -11897,6 +11912,9 @@ msgstr ""
msgid "Copy %{http_label} clone URL"
msgstr ""
+msgid "Copy %{issueType} email address"
+msgstr ""
+
msgid "Copy %{name}"
msgstr ""
@@ -16009,6 +16027,9 @@ msgstr ""
msgid "Email a new %{name} to this project"
msgstr ""
+msgid "Email address copied"
+msgstr ""
+
msgid "Email address suffix"
msgstr ""
@@ -21445,6 +21466,12 @@ msgstr ""
msgid "Header message"
msgstr ""
+msgid "HeaderAction|Notifications and other %{issueType} actions have moved to this menu."
+msgstr ""
+
+msgid "HeaderAction|Okay!"
+msgstr ""
+
msgid "HeaderAction|incident"
msgstr ""
@@ -26379,6 +26406,9 @@ msgstr ""
msgid "Lock %{issuableDisplayName}"
msgstr ""
+msgid "Lock %{issuableType}"
+msgstr ""
+
msgid "Lock File?"
msgstr ""
@@ -26388,9 +26418,6 @@ msgstr ""
msgid "Lock memberships to SAML Group Links synchronization"
msgstr ""
-msgid "Lock merge request"
-msgstr ""
-
msgid "Lock not found"
msgstr ""
@@ -27373,18 +27400,12 @@ msgstr ""
msgid "Merge request events"
msgstr ""
-msgid "Merge request locked."
-msgstr ""
-
msgid "Merge request not merged"
msgstr ""
msgid "Merge request reports"
msgstr ""
-msgid "Merge request unlocked."
-msgstr ""
-
msgid "Merge request was scheduled to merge after pipeline succeeds"
msgstr ""
@@ -36486,6 +36507,9 @@ msgstr ""
msgid "Reference"
msgstr ""
+msgid "Reference copied"
+msgstr ""
+
msgid "References"
msgstr ""
@@ -47202,10 +47226,10 @@ msgstr ""
msgid "Unlock"
msgstr ""
-msgid "Unlock account"
+msgid "Unlock %{issuableType}"
msgstr ""
-msgid "Unlock merge request"
+msgid "Unlock account"
msgstr ""
msgid "Unlock more features with GitLab Ultimate"
@@ -49103,9 +49127,6 @@ msgstr ""
msgid "Vulnerability|%{scannerName} (version %{scannerVersion})"
msgstr ""
-msgid "Vulnerability|AI-Assisted Remediation"
-msgstr ""
-
msgid "Vulnerability|Activity"
msgstr ""
@@ -49184,6 +49205,9 @@ msgstr ""
msgid "Vulnerability|Explain this vulnerability"
msgstr ""
+msgid "Vulnerability|Explain this vulnerability and how to mitigate it with AI"
+msgstr ""
+
msgid "Vulnerability|External Security Report"
msgstr ""
@@ -49286,7 +49310,7 @@ msgstr ""
msgid "Vulnerability|There was an unexpected error. %{linkStart}Please try again%{linkEnd}."
msgstr ""
-msgid "Vulnerability|This is an experimental alpha feature that leverages a large language model to provide recommendations. Please use this with caution as we continue to iterate. To learn more about what we have planned, please check out %{linkStart}this issue%{linkEnd} and give us feedback."
+msgid "Vulnerability|This is an experimental feature that uses AI to explain the vulnerability and provide recommendations. Use this feature with caution as we continue to iterate. Please provide your feedback and ideas in this %{linkStart}issue%{linkEnd}."
msgstr ""
msgid "Vulnerability|Tool"
@@ -52771,6 +52795,9 @@ msgstr ""
msgid "loading"
msgstr ""
+msgid "locked"
+msgstr ""
+
msgid "locked by %{path_lock_user_name} %{created_at}"
msgstr ""
@@ -53678,6 +53705,9 @@ msgstr ""
msgid "unicode domains should use IDNA encoding"
msgstr ""
+msgid "unlocked"
+msgstr ""
+
msgid "updated"
msgstr ""
diff --git a/spec/features/ide/user_opens_merge_request_spec.rb b/spec/features/ide/user_opens_merge_request_spec.rb
index 0074b4b1eb0..dc280133a20 100644
--- a/spec/features/ide/user_opens_merge_request_spec.rb
+++ b/spec/features/ide/user_opens_merge_request_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'IDE merge request', :js, feature_category: :web_ide do
+ include CookieHelper
+
let(:merge_request) { create(:merge_request, :simple, source_project: project) }
let(:project) { create(:project, :public, :repository) }
let(:user) { project.first_owner }
@@ -12,6 +14,8 @@ RSpec.describe 'IDE merge request', :js, feature_category: :web_ide do
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
+
visit(merge_request_path(merge_request))
end
diff --git a/spec/features/incidents/incident_details_spec.rb b/spec/features/incidents/incident_details_spec.rb
index 709919d0196..a166ff46177 100644
--- a/spec/features/incidents/incident_details_spec.rb
+++ b/spec/features/incidents/incident_details_spec.rb
@@ -94,6 +94,7 @@ RSpec.describe 'Incident details', :js, feature_category: :incident_management d
end
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, issue)
wait_for_requests
@@ -113,6 +114,7 @@ RSpec.describe 'Incident details', :js, feature_category: :incident_management d
end
it 'routes the user to the issue details page when the `issue_type` is set to issue' do
+ set_cookie('new-actions-popover-viewed', 'true')
visit incident_project_issues_path(project, incident)
wait_for_requests
diff --git a/spec/features/issues/discussion_lock_spec.rb b/spec/features/issues/discussion_lock_spec.rb
index 47865d2b6ba..fb9addff1a2 100644
--- a/spec/features/issues/discussion_lock_spec.rb
+++ b/spec/features/issues/discussion_lock_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe 'Discussion Lock', :js, feature_category: :team_planning do
before do
sign_in(user)
+ stub_feature_flags(moved_mr_sidebar: false)
end
context 'when a user is a team member' do
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index 2bd5373b715..665c7307231 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'GFM autocomplete', :js, feature_category: :team_planning do
+ include CookieHelper
+
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') }
@@ -45,6 +47,7 @@ RSpec.describe 'GFM autocomplete', :js, feature_category: :team_planning do
before do
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, issue_to_edit)
wait_for_requests
diff --git a/spec/features/issues/issue_detail_spec.rb b/spec/features/issues/issue_detail_spec.rb
index d5f90bb9260..29a61d584ee 100644
--- a/spec/features/issues/issue_detail_spec.rb
+++ b/spec/features/issues/issue_detail_spec.rb
@@ -98,6 +98,7 @@ RSpec.describe 'Issue Detail', :js, feature_category: :team_planning do
project.add_developer(user_to_be_deleted)
sign_in(user_to_be_deleted)
+ stub_feature_flags(moved_mr_sidebar: false)
visit project_issue_path(project, issue)
wait_for_requests
@@ -129,7 +130,7 @@ RSpec.describe 'Issue Detail', :js, feature_category: :team_planning do
describe 'when an issue `issue_type` is edited' do
before do
sign_in(user)
-
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, issue)
wait_for_requests
end
@@ -163,7 +164,7 @@ RSpec.describe 'Issue Detail', :js, feature_category: :team_planning do
describe 'when an incident `issue_type` is edited' do
before do
sign_in(user)
-
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, incident)
wait_for_requests
end
diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb
index 2ae347d4f9e..ee71181fba2 100644
--- a/spec/features/issues/issue_sidebar_spec.rb
+++ b/spec/features/issues/issue_sidebar_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
include MobileHelpers
include Features::InviteMembersModalHelpers
+ include CookieHelper
let_it_be(:group) { create(:group, :nested) }
let_it_be(:project) { create(:project, :public, namespace: group) }
@@ -20,6 +21,7 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
context 'when signed in' do
before do
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
end
context 'when concerning the assignee', :js do
@@ -205,6 +207,7 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
context 'as an allowed user' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
project.add_developer(user)
visit_issue(project, issue)
end
@@ -293,6 +296,7 @@ RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
context 'as a guest' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
project.add_guest(user)
visit_issue(project, issue)
end
diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb
index c6cedbc83cd..4ef58918a2b 100644
--- a/spec/features/issues/user_edits_issue_spec.rb
+++ b/spec/features/issues/user_edits_issue_spec.rb
@@ -3,6 +3,8 @@
require "spec_helper"
RSpec.describe "Issues > User edits issue", :js, feature_category: :team_planning do
+ include CookieHelper
+
let_it_be(:project) { create(:project_empty_repo, :public) }
let_it_be(:project_with_milestones) { create(:project_empty_repo, :public) }
let_it_be(:user) { create(:user) }
@@ -18,6 +20,7 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
project.add_developer(user)
project_with_milestones.add_developer(user)
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
end
context "from edit page" do
diff --git a/spec/features/issues/user_toggles_subscription_spec.rb b/spec/features/issues/user_toggles_subscription_spec.rb
index 904fafdf56a..00b04c10d33 100644
--- a/spec/features/issues/user_toggles_subscription_spec.rb
+++ b/spec/features/issues/user_toggles_subscription_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe "User toggles subscription", :js, feature_category: :team_plannin
context 'user is not logged in' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
visit(project_issue_path(project, issue))
end
@@ -20,9 +21,9 @@ RSpec.describe "User toggles subscription", :js, feature_category: :team_plannin
context 'user is logged in' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
project.add_developer(user)
sign_in(user)
-
visit(project_issue_path(project, issue))
end
@@ -52,6 +53,7 @@ RSpec.describe "User toggles subscription", :js, feature_category: :team_plannin
context 'user is logged in without edit permission' do
before do
+ stub_feature_flags(moved_mr_sidebar: false)
sign_in(user2)
visit(project_issue_path(project, issue))
diff --git a/spec/features/merge_request/user_manages_subscription_spec.rb b/spec/features/merge_request/user_manages_subscription_spec.rb
index d4ccc4a93b5..3bcc8255ab7 100644
--- a/spec/features/merge_request/user_manages_subscription_spec.rb
+++ b/spec/features/merge_request/user_manages_subscription_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'User manages subscription', :js, feature_category: :code_review_workflow do
+ include CookieHelper
+
let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:user) { create(:user) }
@@ -10,7 +12,7 @@ RSpec.describe 'User manages subscription', :js, feature_category: :code_review_
before do
stub_feature_flags(moved_mr_sidebar: moved_mr_sidebar_enabled)
-
+ set_cookie('new-actions-popover-viewed', 'true')
project.add_maintainer(user)
sign_in(user)
diff --git a/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb b/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb
index 7cb1c95f6dc..601310cbacf 100644
--- a/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb
+++ b/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Merge request > User opens checkout branch modal', :js, feature_category: :code_review_workflow do
include ProjectForksHelper
+ include CookieHelper
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
@@ -11,6 +12,7 @@ RSpec.describe 'Merge request > User opens checkout branch modal', :js, feature_
before do
project.add_maintainer(user)
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
end
describe 'for fork' do
diff --git a/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb b/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb
index ad2ceeb23e2..21c62b0d0d8 100644
--- a/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb
+++ b/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Merge request > User sees check out branch modal', :js, feature_category: :code_review_workflow do
+ include CookieHelper
+
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
let(:merge_request) { create(:merge_request, source_project: project) }
@@ -10,6 +12,7 @@ RSpec.describe 'Merge request > User sees check out branch modal', :js, feature_
before do
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_merge_request_path(project, merge_request)
wait_for_requests
diff --git a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
index 0de59ea21c5..dae28cbb05c 100644
--- a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
+++ b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Merge request > User selects branches for new MR', :js, feature_category: :code_review_workflow do
include ListboxHelpers
+ include CookieHelper
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
@@ -17,6 +18,7 @@ RSpec.describe 'Merge request > User selects branches for new MR', :js, feature_
before do
project.add_maintainer(user)
sign_in(user)
+ set_cookie('new-actions-popover-viewed', 'true')
end
it 'selects the source branch sha when a tag with the same name exists' do
diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb
index adf410ce6e8..77f88994bfb 100644
--- a/spec/features/projects/issuable_templates_spec.rb
+++ b/spec/features/projects/issuable_templates_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'issuable templates', :js, feature_category: :projects do
include ProjectForksHelper
+ include CookieHelper
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
@@ -12,6 +13,7 @@ RSpec.describe 'issuable templates', :js, feature_category: :projects do
before do
project.add_maintainer(user)
sign_in user
+ set_cookie('new-actions-popover-viewed', 'true')
end
context 'user creates an issue using templates' do
diff --git a/spec/features/reportable_note/issue_spec.rb b/spec/features/reportable_note/issue_spec.rb
index 55e7f5897bc..a18cdf27294 100644
--- a/spec/features/reportable_note/issue_spec.rb
+++ b/spec/features/reportable_note/issue_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Reportable note on issue', :js, feature_category: :team_planning do
+ include CookieHelper
+
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
@@ -11,7 +13,7 @@ RSpec.describe 'Reportable note on issue', :js, feature_category: :team_planning
before do
project.add_maintainer(user)
sign_in(user)
-
+ set_cookie('new-actions-popover-viewed', 'true')
visit project_issue_path(project, issue)
end
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
index 4585a6616e1..e080e665a3b 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -17,11 +17,11 @@ import {
MEMBERS_MODAL_CELEBRATE_TITLE,
MEMBERS_PLACEHOLDER,
MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
- LEARN_GITLAB,
EXPANDED_ERRORS,
EMPTY_INVITES_ALERT_TEXT,
ON_CELEBRATION_TRACK_LABEL,
INVITE_MEMBER_MODAL_TRACKING_CATEGORY,
+ INVALID_FEEDBACK_MESSAGE_DEFAULT,
} from '~/invite_members/constants';
import eventHub from '~/invite_members/event_hub';
import ContentTransition from '~/vue_shared/components/content_transition.vue';
@@ -39,7 +39,9 @@ import {
import { GROUPS_INVITATIONS_PATH, invitationsApiResponse } from '../mock_data/api_responses';
import {
propsData,
- inviteSource,
+ emailPostData,
+ postData,
+ singleUserPostData,
newProjectPath,
user1,
user2,
@@ -211,15 +213,6 @@ describe('InviteMembersModal', () => {
expect(findTasksToBeDone().exists()).toBe(false);
});
-
- describe('when opened from the Learn GitLab page', () => {
- it('does render the tasks to be done', async () => {
- await setupComponent({}, []);
- await triggerOpenModal({ source: LEARN_GITLAB });
-
- expect(findTasksToBeDone().exists()).toBe(true);
- });
- });
});
describe('rendering the tasks', () => {
@@ -472,16 +465,6 @@ describe('InviteMembersModal', () => {
});
describe('when inviting an existing user to group by user ID', () => {
- const postData = {
- user_id: '1,2',
- access_level: propsData.defaultAccessLevel,
- expires_at: undefined,
- invite_source: inviteSource,
- format: 'json',
- tasks_to_be_done: [],
- tasks_project_id: '',
- };
-
describe('when reloadOnSubmit is true', () => {
beforeEach(async () => {
createComponent({ reloadPageOnSubmit: true });
@@ -535,20 +518,6 @@ describe('InviteMembersModal', () => {
expect(reloadOnInvitationSuccess).not.toHaveBeenCalled();
});
});
-
- describe('when opened from a Learn GitLab page', () => {
- it('emits the `showSuccessfulInvitationsAlert` event', async () => {
- await triggerOpenModal({ source: LEARN_GITLAB });
-
- jest.spyOn(eventHub, '$emit').mockImplementation();
-
- clickInviteButton();
-
- await waitForPromises();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('showSuccessfulInvitationsAlert');
- });
- });
});
describe('when member is not added successfully', () => {
@@ -655,16 +624,6 @@ describe('InviteMembersModal', () => {
});
describe('when inviting a new user by email address', () => {
- const postData = {
- access_level: propsData.defaultAccessLevel,
- expires_at: undefined,
- email: 'email@example.com',
- invite_source: inviteSource,
- tasks_to_be_done: [],
- tasks_project_id: '',
- format: 'json',
- };
-
describe('when invites are sent successfully', () => {
beforeEach(async () => {
createComponent();
@@ -672,7 +631,7 @@ describe('InviteMembersModal', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: emailPostData });
});
describe('when triggered from regular mounting', () => {
@@ -681,7 +640,7 @@ describe('InviteMembersModal', () => {
});
it('calls Api inviteGroupMembers with the correct params', () => {
- expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, emailPostData);
});
it('displays the successful toastMessage', () => {
@@ -699,96 +658,117 @@ describe('InviteMembersModal', () => {
});
describe('when invites are not sent successfully', () => {
- beforeEach(async () => {
- createInviteMembersToGroupWrapper();
+ describe('when api throws error', () => {
+ beforeEach(async () => {
+ jest.spyOn(axios, 'post').mockImplementation(() => {
+ throw new Error();
+ });
- await triggerMembersTokenSelect([user3]);
+ createInviteMembersToGroupWrapper();
+
+ await triggerMembersTokenSelect([user3]);
+ clickInviteButton();
+ });
+
+ it('displays the default error message', () => {
+ expect(membersFormGroupInvalidFeedback()).toBe(INVALID_FEEDBACK_MESSAGE_DEFAULT);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ expect(findActionButton().props('loading')).toBe(false);
+ });
});
- it('displays the api error for invalid email syntax', async () => {
- mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
+ describe('when api rejects promise', () => {
+ beforeEach(async () => {
+ createInviteMembersToGroupWrapper();
- clickInviteButton();
+ await triggerMembersTokenSelect([user3]);
+ });
- await waitForPromises();
+ it('displays the api error for invalid email syntax', async () => {
+ mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
- expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- expect(findMembersSelect().props('exceptionState')).toBe(false);
- expect(findActionButton().props('loading')).toBe(false);
- });
+ clickInviteButton();
- it('clears the error when the modal is hidden', async () => {
- mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
+ await waitForPromises();
- clickInviteButton();
+ expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ expect(findActionButton().props('loading')).toBe(false);
+ });
- await waitForPromises();
+ it('clears the error when the modal is hidden', async () => {
+ mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
- expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- expect(findMembersSelect().props('exceptionState')).toBe(false);
- expect(findActionButton().props('loading')).toBe(false);
+ clickInviteButton();
- findModal().vm.$emit('hidden');
+ await waitForPromises();
- await nextTick();
+ expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ expect(findActionButton().props('loading')).toBe(false);
- expect(findMemberErrorAlert().exists()).toBe(false);
- expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('exceptionState')).not.toBe(false);
- });
+ findModal().vm.$emit('hidden');
- it('displays the restricted email error when restricted email is invited', async () => {
- mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
+ await nextTick();
- clickInviteButton();
+ expect(findMemberErrorAlert().exists()).toBe(false);
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
+ });
- await waitForPromises();
+ it('displays the restricted email error when restricted email is invited', async () => {
+ mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
- expect(findMemberErrorAlert().exists()).toBe(true);
- expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError);
- expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('exceptionState')).not.toBe(false);
- expect(findActionButton().props('loading')).toBe(false);
- });
+ clickInviteButton();
- it('displays all errors when there are multiple emails that return a restricted error message', async () => {
- mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
+ await waitForPromises();
- clickInviteButton();
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError);
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
+ expect(findActionButton().props('loading')).toBe(false);
+ });
- await waitForPromises();
+ it('displays all errors when there are multiple emails that return a restricted error message', async () => {
+ mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
- expect(findMemberErrorAlert().exists()).toBe(true);
- expect(findMemberErrorAlert().text()).toContain(
- Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0],
- );
- expect(findMemberErrorAlert().text()).toContain(
- Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1],
- );
- expect(findMemberErrorAlert().text()).toContain(
- Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2],
- );
- expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('exceptionState')).not.toBe(false);
- });
+ clickInviteButton();
- it('displays the invalid syntax error for bad request', async () => {
- mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID);
+ await waitForPromises();
- clickInviteButton();
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0],
+ );
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1],
+ );
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2],
+ );
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
+ });
- await waitForPromises();
+ it('displays the invalid syntax error for bad request', async () => {
+ mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID);
- expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- expect(findMembersSelect().props('exceptionState')).toBe(false);
- });
+ clickInviteButton();
- it('does not call displaySuccessfulInvitationAlert on mount', () => {
- expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled();
- });
+ await waitForPromises();
+
+ expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ });
+
+ it('does not call displaySuccessfulInvitationAlert on mount', () => {
+ expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled();
+ });
- it('does not call reloadOnInvitationSuccess', () => {
- expect(reloadOnInvitationSuccess).not.toHaveBeenCalled();
+ it('does not call reloadOnInvitationSuccess', () => {
+ expect(reloadOnInvitationSuccess).not.toHaveBeenCalled();
+ });
});
});
@@ -872,17 +852,6 @@ describe('InviteMembersModal', () => {
});
describe('when inviting members and non-members in same click', () => {
- const postData = {
- access_level: propsData.defaultAccessLevel,
- expires_at: undefined,
- invite_source: inviteSource,
- format: 'json',
- tasks_to_be_done: [],
- tasks_project_id: '',
- user_id: '1',
- email: 'email@example.com',
- };
-
describe('when invites are sent successfully', () => {
beforeEach(async () => {
createComponent();
@@ -890,7 +859,7 @@ describe('InviteMembersModal', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: singleUserPostData });
});
describe('when triggered from regular mounting', () => {
@@ -902,7 +871,7 @@ describe('InviteMembersModal', () => {
it('calls Api inviteGroupMembers with the correct params and invite source', () => {
expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, {
- ...postData,
+ ...singleUserPostData,
invite_source: '_invite_source_',
});
});
@@ -931,7 +900,7 @@ describe('InviteMembersModal', () => {
clickInviteButton();
- expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, singleUserPostData);
});
});
});
diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js
index 59d58f21bb0..67fb1dcbfbd 100644
--- a/spec/frontend/invite_members/mock_data/member_modal.js
+++ b/spec/frontend/invite_members/mock_data/member_modal.js
@@ -45,4 +45,35 @@ export const user6 = {
avatar_url: '',
};
+export const postData = {
+ user_id: `${user1.id},${user2.id}`,
+ access_level: propsData.defaultAccessLevel,
+ expires_at: undefined,
+ invite_source: inviteSource,
+ format: 'json',
+ tasks_to_be_done: [],
+ tasks_project_id: '',
+};
+
+export const emailPostData = {
+ access_level: propsData.defaultAccessLevel,
+ expires_at: undefined,
+ email: `${user3.name}`,
+ invite_source: inviteSource,
+ tasks_to_be_done: [],
+ tasks_project_id: '',
+ format: 'json',
+};
+
+export const singleUserPostData = {
+ access_level: propsData.defaultAccessLevel,
+ expires_at: undefined,
+ user_id: `${user1.id}`,
+ email: `${user3.name}`,
+ invite_source: inviteSource,
+ tasks_to_be_done: [],
+ tasks_project_id: '',
+ format: 'json',
+};
+
export const GlEmoji = { template: '<img/>' };
diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js
index eb76c9845d4..b6fc70038bb 100644
--- a/spec/frontend/invite_members/utils/member_utils_spec.js
+++ b/spec/frontend/invite_members/utils/member_utils_spec.js
@@ -1,4 +1,12 @@
-import { memberName } from '~/invite_members/utils/member_utils';
+import {
+ memberName,
+ triggerExternalAlert,
+ qualifiesForTasksToBeDone,
+} from '~/invite_members/utils/member_utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { getParameterValues } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility');
describe('Member Name', () => {
it.each([
@@ -10,3 +18,23 @@ describe('Member Name', () => {
expect(memberName(member)).toBe(result);
});
});
+
+describe('Trigger External Alert', () => {
+ it('returns false', () => {
+ expect(triggerExternalAlert()).toBe(false);
+ });
+});
+
+describe('Qualifies For Tasks To Be Done', () => {
+ it.each([
+ ['invite_members_for_task', true],
+ ['blah', false],
+ ])(`returns name from supplied member token: %j`, (value, result) => {
+ setWindowLocation(`blah/blah?open_modal=${value}`);
+ getParameterValues.mockImplementation(() => {
+ return [value];
+ });
+
+ expect(qualifiesForTasksToBeDone()).toBe(result);
+ });
+});
diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js
index db3435855f6..a5ba512434c 100644
--- a/spec/frontend/issues/show/components/header_actions_spec.js
+++ b/spec/frontend/issues/show/components/header_actions_spec.js
@@ -2,6 +2,8 @@ import Vue, { nextTick } from 'vue';
import { GlDropdownItem, GlLink, GlModal, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
+import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
import { mockTracking } from 'helpers/tracking_helper';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { STATUS_CLOSED, STATUS_OPEN, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
@@ -14,17 +16,22 @@ import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutatio
import * as urlUtility from '~/lib/utils/url_utility';
import eventHub from '~/notes/event_hub';
import createStore from '~/notes/stores';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
+import updateIssueMutation from '~/issues/show/queries/update_issue.mutation.graphql';
+import toast from '~/vue_shared/plugins/global_toast';
jest.mock('~/alert');
jest.mock('~/issues/show/event_hub', () => ({ $emit: jest.fn() }));
+jest.mock('~/vue_shared/plugins/global_toast');
describe('HeaderActions component', () => {
let dispatchEventSpy;
- let mutateMock;
let wrapper;
let visitUrlSpy;
Vue.use(Vuex);
+ Vue.use(VueApollo);
const store = createStore();
@@ -45,15 +52,28 @@ describe('HeaderActions component', () => {
reportedUserId: 1,
reportedFromUrl: 'http://localhost:/gitlab-org/-/issues/32',
submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam',
+ issuableEmailAddress: null,
+ fullPath: 'full-path',
};
- const updateIssueMutationResponse = { data: { updateIssue: { errors: [] } } };
+ const updateIssueMutationResponse = {
+ data: {
+ updateIssue: {
+ errors: [],
+ issuable: {
+ id: 'gid://gitlab/Issue/511',
+ state: STATUS_OPEN,
+ },
+ },
+ },
+ };
const promoteToEpicMutationResponse = {
data: {
promoteToEpic: {
errors: [],
epic: {
+ id: 'gid://gitlab/Epic/1',
webPath: '/groups/gitlab-org/-/epics/1',
},
},
@@ -69,6 +89,20 @@ describe('HeaderActions component', () => {
},
};
+ const mockIssueReferenceData = {
+ data: {
+ workspace: {
+ id: 'gid://gitlab/Project/7',
+ issuable: {
+ id: 'gid://gitlab/Issue/511',
+ reference: 'flightjs/Flight#33',
+ __typename: 'Issue',
+ },
+ __typename: 'Project',
+ },
+ },
+ };
+
const findToggleIssueStateButton = () => wrapper.find(`[data-testid="toggle-button"]`);
const findEditButton = () => wrapper.find(`[data-testid="edit-button"]`);
@@ -77,33 +111,54 @@ describe('HeaderActions component', () => {
const findDesktopDropdown = () => findDropdownBy('desktop-dropdown');
const findMobileDropdownItems = () => findMobileDropdown().findAllComponents(GlDropdownItem);
const findDesktopDropdownItems = () => findDesktopDropdown().findAllComponents(GlDropdownItem);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
+ const findReportAbuseSelectorItem = () => wrapper.find(`[data-testid="report-abuse-item"]`);
+ const findNotificationWidget = () => wrapper.find(`[data-testid="notification-toggle"]`);
+ const findLockIssueWidget = () => wrapper.find(`[data-testid="lock-issue-toggle"]`);
+ const findCopyRefenceDropdownItem = () => wrapper.find(`[data-testid="copy-reference"]`);
+ const findCopyEmailItem = () => wrapper.find(`[data-testid="copy-email"]`);
const findModal = () => wrapper.findComponent(GlModal);
const findModalLinkAt = (index) => findModal().findAllComponents(GlLink).at(index);
+ const issueReferenceSuccessHandler = jest.fn().mockResolvedValue(mockIssueReferenceData);
+ const updateIssueMutationResponseHandler = jest
+ .fn()
+ .mockResolvedValue(updateIssueMutationResponse);
+ const promoteToEpicMutationSuccessResponseHandler = jest
+ .fn()
+ .mockResolvedValue(promoteToEpicMutationResponse);
+ const promoteToEpicMutationErrorHandler = jest
+ .fn()
+ .mockResolvedValue(promoteToEpicMutationErrorResponse);
+
const mountComponent = ({
props = {},
issueState = STATUS_OPEN,
blockedByIssues = [],
- mutateResponse = {},
+ movedMrSidebarEnabled = false,
+ promoteToEpicHandler = promoteToEpicMutationSuccessResponseHandler,
} = {}) => {
- mutateMock = jest.fn().mockResolvedValue(mutateResponse);
-
store.dispatch('setNoteableData', {
blocked_by_issues: blockedByIssues,
state: issueState,
});
+ const handlers = [
+ [issueReferenceQuery, issueReferenceSuccessHandler],
+ [updateIssueMutation, updateIssueMutationResponseHandler],
+ [promoteToEpicMutation, promoteToEpicHandler],
+ ];
+
return shallowMount(HeaderActions, {
+ apolloProvider: createMockApollo(handlers),
store,
provide: {
...defaultProps,
...props,
- },
- mocks: {
- $apollo: {
- mutate: mutateMock,
+ glFeatures: {
+ movedMrSidebar: movedMrSidebarEnabled,
},
},
stubs: {
@@ -138,7 +193,6 @@ describe('HeaderActions component', () => {
wrapper = mountComponent({
props: { issueType },
issueState,
- mutateResponse: updateIssueMutationResponse,
});
});
@@ -149,23 +203,19 @@ describe('HeaderActions component', () => {
it('calls apollo mutation', () => {
findToggleIssueStateButton().vm.$emit('click');
- expect(mutateMock).toHaveBeenCalledWith(
- expect.objectContaining({
- variables: {
- input: {
- iid: defaultProps.iid,
- projectPath: defaultProps.projectPath,
- stateEvent: newIssueState,
- },
- },
- }),
- );
+ expect(updateIssueMutationResponseHandler).toHaveBeenCalledWith({
+ input: {
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ stateEvent: newIssueState,
+ },
+ });
});
it('dispatches a custom event to update the issue page', async () => {
findToggleIssueStateButton().vm.$emit('click');
- await nextTick();
+ await waitForPromises();
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
});
@@ -290,28 +340,25 @@ describe('HeaderActions component', () => {
describe('when "Promote to epic" button is clicked', () => {
describe('when response is successful', () => {
- beforeEach(() => {
+ beforeEach(async () => {
visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
wrapper = mountComponent({
- mutateResponse: promoteToEpicMutationResponse,
+ promoteToEpicHandler: promoteToEpicMutationSuccessResponseHandler,
});
wrapper.find('[data-testid="promote-button"]').vm.$emit('click');
+
+ await waitForPromises();
});
it('invokes GraphQL mutation when clicked', () => {
- expect(mutateMock).toHaveBeenCalledWith(
- expect.objectContaining({
- mutation: promoteToEpicMutation,
- variables: {
- input: {
- iid: defaultProps.iid,
- projectPath: defaultProps.projectPath,
- },
- },
- }),
- );
+ expect(promoteToEpicMutationSuccessResponseHandler).toHaveBeenCalledWith({
+ input: {
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ },
+ });
});
it('shows a success message and tells the user they are being redirected', () => {
@@ -329,14 +376,16 @@ describe('HeaderActions component', () => {
});
describe('when response contains errors', () => {
- beforeEach(() => {
+ beforeEach(async () => {
visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
wrapper = mountComponent({
- mutateResponse: promoteToEpicMutationErrorResponse,
+ promoteToEpicHandler: promoteToEpicMutationErrorHandler,
});
wrapper.find('[data-testid="promote-button"]').vm.$emit('click');
+
+ await waitForPromises();
});
it('shows an error message', () => {
@@ -349,21 +398,17 @@ describe('HeaderActions component', () => {
describe('when `toggle.issuable.state` event is emitted', () => {
it('invokes a method to toggle the issue state', () => {
- wrapper = mountComponent({ mutateResponse: updateIssueMutationResponse });
+ wrapper = mountComponent();
eventHub.$emit('toggle.issuable.state');
- expect(mutateMock).toHaveBeenCalledWith(
- expect.objectContaining({
- variables: {
- input: {
- iid: defaultProps.iid,
- projectPath: defaultProps.projectPath,
- stateEvent: ISSUE_STATE_EVENT_CLOSE,
- },
- },
- }),
- );
+ expect(updateIssueMutationResponseHandler).toHaveBeenCalledWith({
+ input: {
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ stateEvent: ISSUE_STATE_EVENT_CLOSE,
+ },
+ });
});
});
@@ -392,17 +437,13 @@ describe('HeaderActions component', () => {
it('calls apollo mutation when primary button is clicked', () => {
findModal().vm.$emit('primary');
- expect(mutateMock).toHaveBeenCalledWith(
- expect.objectContaining({
- variables: {
- input: {
- iid: defaultProps.iid.toString(),
- projectPath: defaultProps.projectPath,
- stateEvent: ISSUE_STATE_EVENT_CLOSE,
- },
- },
- }),
- );
+ expect(updateIssueMutationResponseHandler).toHaveBeenCalledWith({
+ input: {
+ iid: defaultProps.iid.toString(),
+ projectPath: defaultProps.projectPath,
+ stateEvent: ISSUE_STATE_EVENT_CLOSE,
+ },
+ });
});
describe.each`
@@ -434,8 +475,6 @@ describe('HeaderActions component', () => {
});
describe('abuse category selector', () => {
- const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
-
beforeEach(() => {
wrapper = mountComponent({ props: { isIssueAuthor: false } });
});
@@ -445,7 +484,7 @@ describe('HeaderActions component', () => {
});
it('opens the drawer', async () => {
- findDesktopDropdownItems().at(2).vm.$emit('click');
+ findReportAbuseSelectorItem().vm.$emit('click');
await nextTick();
@@ -453,10 +492,160 @@ describe('HeaderActions component', () => {
});
it('closes the drawer', async () => {
- await findDesktopDropdownItems().at(2).vm.$emit('click');
+ await findReportAbuseSelectorItem().vm.$emit('click');
await findAbuseCategorySelector().vm.$emit('close-drawer');
expect(findAbuseCategorySelector().exists()).toEqual(false);
});
});
+
+ describe('notification toggle', () => {
+ describe('visibility', () => {
+ describe.each`
+ movedMrSidebarEnabled | issueType | visible
+ ${true} | ${TYPE_ISSUE} | ${true}
+ ${true} | ${TYPE_INCIDENT} | ${true}
+ ${false} | ${TYPE_ISSUE} | ${false}
+ ${false} | ${TYPE_INCIDENT} | ${false}
+ `(
+ `when movedMrSidebarEnabled flag is "$movedMrSidebarEnabled" with issue type "$issueType"`,
+ ({ movedMrSidebarEnabled, issueType, visible }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType,
+ },
+ movedMrSidebarEnabled,
+ });
+ });
+
+ it(`${visible ? 'shows' : 'hides'} Notification toggle`, () => {
+ expect(findNotificationWidget().exists()).toBe(visible);
+ });
+ },
+ );
+ });
+ });
+
+ describe('lock issue option', () => {
+ describe('visibility', () => {
+ describe.each`
+ movedMrSidebarEnabled | issueType | visible
+ ${true} | ${TYPE_ISSUE} | ${true}
+ ${true} | ${TYPE_INCIDENT} | ${false}
+ ${false} | ${TYPE_ISSUE} | ${false}
+ ${false} | ${TYPE_INCIDENT} | ${false}
+ `(
+ `when movedMrSidebarEnabled flag is "$movedMrSidebarEnabled" with issue type "$issueType"`,
+ ({ movedMrSidebarEnabled, issueType, visible }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType,
+ },
+ movedMrSidebarEnabled,
+ });
+ });
+
+ it(`${visible ? 'shows' : 'hides'} Lock issue option`, () => {
+ expect(findLockIssueWidget().exists()).toBe(visible);
+ });
+ },
+ );
+ });
+ });
+
+ describe('copy reference option', () => {
+ describe('visibility', () => {
+ describe.each`
+ movedMrSidebarEnabled | issueType | visible
+ ${true} | ${TYPE_ISSUE} | ${true}
+ ${true} | ${TYPE_INCIDENT} | ${true}
+ ${false} | ${TYPE_ISSUE} | ${false}
+ ${false} | ${TYPE_INCIDENT} | ${false}
+ `(
+ 'when movedMrSidebarFlagEnabled is "$movedMrSidebarEnabled" with issue type "$issueType"',
+ ({ movedMrSidebarEnabled, issueType, visible }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType,
+ },
+ movedMrSidebarEnabled,
+ });
+ });
+
+ it(`${visible ? 'shows' : 'hides'} Copy reference option`, () => {
+ expect(findCopyRefenceDropdownItem().exists()).toBe(visible);
+ });
+ },
+ );
+ });
+
+ describe('clicking when visible', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType: TYPE_ISSUE,
+ },
+ movedMrSidebarEnabled: true,
+ });
+ });
+
+ it('shows toast message', () => {
+ findCopyRefenceDropdownItem().vm.$emit('click');
+
+ expect(toast).toHaveBeenCalledWith('Reference copied');
+ });
+ });
+ });
+
+ describe('copy email option', () => {
+ describe('visibility', () => {
+ describe.each`
+ movedMrSidebarEnabled | issueType | issuableEmailAddress | visible
+ ${true} | ${TYPE_ISSUE} | ${'mock-email-address'} | ${true}
+ ${true} | ${TYPE_ISSUE} | ${''} | ${false}
+ ${true} | ${TYPE_INCIDENT} | ${'mock-email-address'} | ${true}
+ ${true} | ${TYPE_INCIDENT} | ${''} | ${false}
+ ${false} | ${TYPE_ISSUE} | ${'mock-email-address'} | ${false}
+ ${false} | ${TYPE_INCIDENT} | ${'mock-email-address'} | ${false}
+ `(
+ 'when movedMrSidebarEnabled flag is "$movedMrSidebarEnabled" issue type is "$issueType" and issuableEmailAddress="$issuableEmailAddress"',
+ ({ movedMrSidebarEnabled, issueType, issuableEmailAddress, visible }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType,
+ issuableEmailAddress,
+ },
+ movedMrSidebarEnabled,
+ });
+ });
+
+ it(`${visible ? 'shows' : 'hides'} Copy email option`, () => {
+ expect(findCopyEmailItem().exists()).toBe(visible);
+ });
+ },
+ );
+ });
+
+ describe('clicking when visible', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ issueType: TYPE_ISSUE,
+ issuableEmailAddress: 'mock-email-address',
+ },
+ movedMrSidebarEnabled: true,
+ });
+ });
+
+ it('shows toast message', () => {
+ findCopyEmailItem().vm.$emit('click');
+
+ expect(toast).toHaveBeenCalledWith('Email address copied');
+ });
+ });
+ });
});
diff --git a/spec/frontend/issues/show/components/new_header_actions_popover_spec.js b/spec/frontend/issues/show/components/new_header_actions_popover_spec.js
new file mode 100644
index 00000000000..bf3e81c7d3a
--- /dev/null
+++ b/spec/frontend/issues/show/components/new_header_actions_popover_spec.js
@@ -0,0 +1,77 @@
+import { GlPopover } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue';
+import { NEW_ACTIONS_POPOVER_KEY } from '~/issues/show/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
+import * as utils from '~/lib/utils/common_utils';
+
+describe('NewHeaderActionsPopover', () => {
+ let wrapper;
+
+ const createComponent = ({ issueType = TYPE_ISSUE, movedMrSidebarEnabled = true }) => {
+ wrapper = shallowMountExtended(NewHeaderActionsPopover, {
+ propsData: {
+ issueType,
+ },
+ stubs: {
+ GlPopover,
+ },
+ provide: {
+ glFeatures: {
+ movedMrSidebar: movedMrSidebarEnabled,
+ },
+ },
+ });
+ };
+
+ const findPopover = () => wrapper.findComponent(GlPopover);
+ const findConfirmButton = () => wrapper.findByTestId('confirm-button');
+
+ it('should not be visible when the feature flag :moved_mr_sidebar is disabled', () => {
+ createComponent({ movedMrSidebarEnabled: false });
+ expect(findPopover().exists()).toBe(false);
+ });
+
+ describe('without the popover cookie', () => {
+ beforeEach(() => {
+ utils.setCookie = jest.fn();
+
+ createComponent({});
+ });
+
+ it('renders the popover with correct text', () => {
+ expect(findPopover().exists()).toBe(true);
+ expect(findPopover().text()).toContain('issue actions');
+ });
+
+ it('does not call setCookie', () => {
+ expect(utils.setCookie).not.toHaveBeenCalled();
+ });
+
+ describe('when the confirm button is clicked', () => {
+ beforeEach(() => {
+ findConfirmButton().vm.$emit('click');
+ });
+
+ it('sets the popover cookie', () => {
+ expect(utils.setCookie).toHaveBeenCalledWith(NEW_ACTIONS_POPOVER_KEY, true);
+ });
+
+ it('hides the popover', () => {
+ expect(findPopover().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with the popover cookie', () => {
+ beforeEach(() => {
+ jest.spyOn(utils, 'getCookie').mockReturnValue('true');
+
+ createComponent({});
+ });
+
+ it('does not render the popover', () => {
+ expect(findPopover().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js
index d26ef7298ce..5e766e9a41c 100644
--- a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js
+++ b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js
@@ -29,6 +29,7 @@ describe('IssuableLockForm', () => {
const findEditForm = () => wrapper.findComponent(EditForm);
const findSidebarLockStatusTooltip = () =>
getBinding(findSidebarCollapseIcon().element, 'gl-tooltip');
+ const findIssuableLockClickable = () => wrapper.find('[data-testid="issuable-lock"]');
const initStore = (isLocked) => {
if (issuableType === ISSUABLE_TYPE_ISSUE) {
@@ -48,7 +49,7 @@ describe('IssuableLockForm', () => {
store.getters.getNoteableData.discussion_locked = isLocked;
};
- const createComponent = ({ props = {} }, movedMrSidebar = false) => {
+ const createComponent = ({ props = {}, movedMrSidebar = false }) => {
wrapper = shallowMount(IssuableLockForm, {
store,
provide: {
@@ -169,11 +170,27 @@ describe('IssuableLockForm', () => {
`('displays $message when merge request is $locked', async ({ locked, message }) => {
initStore(locked);
- createComponent({}, true);
+ createComponent({ movedMrSidebar: true });
await wrapper.find('.dropdown-item').trigger('click');
expect(toast).toHaveBeenCalledWith(message);
});
});
+
+ describe('moved_mr_sidebar flag', () => {
+ describe('when the flag is off', () => {
+ it('does not show the non editable lock status', () => {
+ createComponent({ movedMrSidebar: false });
+ expect(findIssuableLockClickable().exists()).toBe(false);
+ });
+ });
+
+ describe('when the flag is on', () => {
+ it('does not show the non editable lock status', () => {
+ createComponent({ movedMrSidebar: true });
+ expect(findIssuableLockClickable().exists()).toBe(true);
+ });
+ });
+ });
});
diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js
index d39fa741574..85f2a63943d 100644
--- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js
+++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { GlCollapse } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue';
@@ -5,25 +6,31 @@ import HelpCenter from '~/super_sidebar/components/help_center.vue';
import UserBar from '~/super_sidebar/components/user_bar.vue';
import SidebarPortalTarget from '~/super_sidebar/components/sidebar_portal_target.vue';
import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue';
-import { isCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager';
+import {
+ SUPER_SIDEBAR_PEEK_OPEN_DELAY,
+ SUPER_SIDEBAR_PEEK_CLOSE_DELAY,
+} from '~/super_sidebar/constants';
import { stubComponent } from 'helpers/stub_component';
import { sidebarData } from '../mock_data';
-jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager', () => ({
- isCollapsed: jest.fn(),
-}));
const focusInputMock = jest.fn();
describe('SuperSidebar component', () => {
let wrapper;
- const findSidebar = () => wrapper.find('.super-sidebar');
+ const findSidebar = () => wrapper.findByTestId('super-sidebar');
+ const findHoverArea = () => wrapper.findByTestId('super-sidebar-hover-area');
const findUserBar = () => wrapper.findComponent(UserBar);
const findHelpCenter = () => wrapper.findComponent(HelpCenter);
const findSidebarPortalTarget = () => wrapper.findComponent(SidebarPortalTarget);
- const createWrapper = (props = {}) => {
+ const createWrapper = ({ props = {}, provide = {}, sidebarState = {} } = {}) => {
wrapper = shallowMountExtended(SuperSidebar, {
+ data() {
+ return {
+ ...sidebarState,
+ };
+ },
propsData: {
sidebarData,
...props,
@@ -33,21 +40,18 @@ describe('SuperSidebar component', () => {
methods: { focusInput: focusInputMock },
}),
},
+ provide,
});
};
describe('default', () => {
- it('adds inert attribute and `gl-visibility-hidden` class when collapsed', () => {
- isCollapsed.mockReturnValue(true);
- createWrapper();
- expect(findSidebar().classes()).toContain('gl-visibility-hidden');
+ it('adds inert attribute when collapsed', () => {
+ createWrapper({ sidebarState: { isCollapsed: true } });
expect(findSidebar().attributes('inert')).toBe('inert');
});
- it('does not add inert attribute and `gl-visibility-hidden` class when expanded', () => {
- isCollapsed.mockReturnValue(false);
+ it('does not add inert attribute when expanded', () => {
createWrapper();
- expect(findSidebar().classes()).not.toContain('gl-visibility-hidden');
expect(findSidebar().attributes('inert')).toBe(undefined);
});
@@ -83,6 +87,93 @@ describe('SuperSidebar component', () => {
});
});
+ describe('when peeking on hover', () => {
+ const peekClass = 'super-sidebar-peek';
+
+ it('updates inert attribute and peek class', async () => {
+ createWrapper({
+ provide: { glFeatures: { superSidebarPeek: true } },
+ sidebarState: { isCollapsed: true },
+ });
+
+ findHoverArea().trigger('mouseenter');
+
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY - 1);
+ await nextTick();
+
+ // Not quite enough time has elapsed yet for sidebar to open
+ expect(findSidebar().classes()).not.toContain(peekClass);
+ expect(findSidebar().attributes('inert')).toBe('inert');
+
+ jest.advanceTimersByTime(1);
+ await nextTick();
+
+ // Exactly enough time has elapsed to open
+ expect(findSidebar().classes()).toContain(peekClass);
+ expect(findSidebar().attributes('inert')).toBe(undefined);
+
+ // Important: assume the cursor enters the sidebar
+ findSidebar().trigger('mouseenter');
+
+ jest.runAllTimers();
+ await nextTick();
+
+ // Sidebar remains peeked open indefinitely without a mouseleave
+ expect(findSidebar().classes()).toContain(peekClass);
+ expect(findSidebar().attributes('inert')).toBe(undefined);
+
+ findSidebar().trigger('mouseleave');
+
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1);
+ await nextTick();
+
+ // Not quite enough time has elapsed yet for sidebar to hide
+ expect(findSidebar().classes()).toContain(peekClass);
+ expect(findSidebar().attributes('inert')).toBe(undefined);
+
+ jest.advanceTimersByTime(1);
+ await nextTick();
+
+ // Exactly enough time has elapsed for sidebar to hide
+ expect(findSidebar().classes()).not.toContain('super-sidebar-peek');
+ expect(findSidebar().attributes('inert')).toBe('inert');
+ });
+
+ it('eventually closes the sidebar if cursor never enters sidebar', async () => {
+ createWrapper({
+ provide: { glFeatures: { superSidebarPeek: true } },
+ sidebarState: { isCollapsed: true },
+ });
+
+ findHoverArea().trigger('mouseenter');
+
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY);
+ await nextTick();
+
+ // Sidebar is now open
+ expect(findSidebar().classes()).toContain(peekClass);
+ expect(findSidebar().attributes('inert')).toBe(undefined);
+
+ // Important: do *not* fire a mouseenter event on the sidebar here. This
+ // imitates what happens if the cursor moves away from the sidebar before
+ // it actually appears.
+
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1);
+ await nextTick();
+
+ // Not quite enough time has elapsed yet for sidebar to hide
+ expect(findSidebar().classes()).toContain(peekClass);
+ expect(findSidebar().attributes('inert')).toBe(undefined);
+
+ jest.advanceTimersByTime(1);
+ await nextTick();
+
+ // Exactly enough time has elapsed for sidebar to hide
+ expect(findSidebar().classes()).not.toContain('super-sidebar-peek');
+ expect(findSidebar().attributes('inert')).toBe('inert');
+ });
+ });
+
describe('when opening the context switcher', () => {
beforeEach(() => {
createWrapper();
diff --git a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js
new file mode 100644
index 00000000000..b9f94e662fe
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js
@@ -0,0 +1,106 @@
+import { nextTick } from 'vue';
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS } from '~/super_sidebar/constants';
+import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue';
+import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager';
+
+jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager.js', () => ({
+ toggleSuperSidebarCollapsed: jest.fn(),
+}));
+
+describe('SuperSidebarToggle component', () => {
+ let wrapper;
+
+ const findButton = () => wrapper.findComponent(GlButton);
+ const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
+
+ const createWrapper = ({ props = {}, sidebarState = {} } = {}) => {
+ wrapper = shallowMountExtended(SuperSidebarToggle, {
+ data() {
+ return {
+ ...sidebarState,
+ };
+ },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ describe('attributes', () => {
+ it('has aria-controls attribute', () => {
+ createWrapper();
+ expect(findButton().attributes('aria-controls')).toBe('super-sidebar');
+ });
+
+ it('has aria-expanded as true when expanded', () => {
+ createWrapper();
+ expect(findButton().attributes('aria-expanded')).toBe('true');
+ });
+
+ it('has aria-expanded as false when collapsed', () => {
+ createWrapper({ sidebarState: { isCollapsed: true } });
+ expect(findButton().attributes('aria-expanded')).toBe('false');
+ });
+
+ it('has aria-label attribute', () => {
+ createWrapper();
+ expect(findButton().attributes('aria-label')).toBe(__('Navigation sidebar'));
+ });
+
+ it('is disabled when isPeek is true', () => {
+ createWrapper({ sidebarState: { isPeek: true } });
+ expect(findButton().attributes('disabled')).toBe('true');
+ });
+ });
+
+ describe('toolip', () => {
+ it('displays collapse when expanded', () => {
+ createWrapper();
+ expect(getTooltip().title).toBe(__('Collapse sidebar'));
+ });
+
+ it('displays expand when collapsed', () => {
+ createWrapper({ sidebarState: { isCollapsed: true } });
+ expect(getTooltip().title).toBe(__('Expand sidebar'));
+ });
+ });
+
+ describe('toggle', () => {
+ beforeEach(() => {
+ setHTMLFixture(`
+ <button class="${JS_TOGGLE_COLLAPSE_CLASS}">Collapse</button>
+ <button class="${JS_TOGGLE_EXPAND_CLASS}">Expand</button>
+ `);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ it('collapses the sidebar and focuses the other toggle', async () => {
+ createWrapper();
+ findButton().vm.$emit('click');
+ await nextTick();
+ expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(true, true);
+ expect(document.activeElement).toEqual(
+ document.querySelector(`.${JS_TOGGLE_COLLAPSE_CLASS}`),
+ );
+ });
+
+ it('expands the sidebar and focuses the other toggle', async () => {
+ createWrapper({ sidebarState: { isCollapsed: true } });
+ findButton().vm.$emit('click');
+ await nextTick();
+ expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(false, true);
+ expect(document.activeElement).toEqual(document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`));
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js
index cc0ca90dc39..2b75fb27972 100644
--- a/spec/frontend/super_sidebar/components/user_bar_spec.js
+++ b/spec/frontend/super_sidebar/components/user_bar_spec.js
@@ -28,6 +28,7 @@ describe('UserBar component', () => {
const findTodosCounter = () => findCounter(2);
const findMergeRequestMenu = () => wrapper.findComponent(MergeRequestMenu);
const findBrandLogo = () => wrapper.findByTestId('brand-header-custom-logo');
+ const findCollapseButton = () => wrapper.findByTestId('super-sidebar-collapse-button');
const findSearchButton = () => wrapper.findByTestId('super-sidebar-search-button');
const findSearchModal = () => wrapper.findComponent(SearchModal);
const findStopImpersonationButton = () => wrapper.findByTestId('stop-impersonation-btn');
@@ -39,9 +40,14 @@ describe('UserBar component', () => {
searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
},
});
- const createWrapper = ({ extraSidebarData = {}, provideOverrides = {} } = {}) => {
+ const createWrapper = ({
+ hasCollapseButton = true,
+ extraSidebarData = {},
+ provideOverrides = {},
+ } = {}) => {
wrapper = shallowMountExtended(UserBar, {
propsData: {
+ hasCollapseButton,
sidebarData: { ...sidebarData, ...extraSidebarData },
},
provide: {
@@ -119,6 +125,15 @@ describe('UserBar component', () => {
it('does not render the "Stop impersonating" button', () => {
expect(findStopImpersonationButton().exists()).toBe(false);
});
+
+ it('renders collapse button when hasCollapseButton is true', () => {
+ expect(findCollapseButton().exists()).toBe(true);
+ });
+
+ it('does not render collapse button when hasCollapseButton is false', () => {
+ createWrapper({ hasCollapseButton: false });
+ expect(findCollapseButton().exists()).toBe(false);
+ });
});
describe('GitLab Next badge', () => {
diff --git a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js
index 36b14a6451f..cadcf8c08a3 100644
--- a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js
+++ b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js
@@ -1,16 +1,14 @@
import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { sidebarState } from '~/super_sidebar/constants';
import {
SIDEBAR_COLLAPSED_CLASS,
SIDEBAR_COLLAPSED_COOKIE,
SIDEBAR_COLLAPSED_COOKIE_EXPIRATION,
toggleSuperSidebarCollapsed,
initSuperSidebarCollapsedState,
- bindSuperSidebarCollapsedEvents,
findPage,
- findSidebar,
- findToggle,
} from '~/super_sidebar/super_sidebar_collapsed_state_manager';
const { xl, sm } = breakpoints;
@@ -33,7 +31,6 @@ describe('Super Sidebar Collapsed State Manager', () => {
setHTMLFixture(`
<div class="page-with-super-sidebar">
<aside class="super-sidebar"></aside>
- <button class="js-super-sidebar-toggle"></button>
</div>
`);
});
@@ -61,7 +58,7 @@ describe('Super Sidebar Collapsed State Manager', () => {
toggleSuperSidebarCollapsed(collapsed, saveCookie);
pageHasCollapsedClass(hasClass);
- expect(findSidebar().inert).toBe(collapsed);
+ expect(sidebarState.isCollapsed).toBe(collapsed);
if (saveCookie && windowWidth >= xl) {
expect(setCookie).toHaveBeenCalledWith(SIDEBAR_COLLAPSED_COOKIE, collapsed, {
@@ -72,69 +69,6 @@ describe('Super Sidebar Collapsed State Manager', () => {
}
},
);
-
- describe('toggling the super sidebar', () => {
- let sidebar;
- let toggle;
-
- beforeEach(() => {
- sidebar = findSidebar();
- toggle = findToggle();
- jest.spyOn(toggle, 'focus');
- jest.spyOn(sidebar, 'focus');
- });
-
- afterEach(() => {
- sidebar = null;
- toggle = null;
- });
-
- describe('collapsing the sidebar', () => {
- const collapse = true;
-
- describe('on user action', () => {
- it('hides the sidebar, then focuses the toggle', () => {
- toggleSuperSidebarCollapsed(collapse, false, true);
- jest.runAllTimers();
-
- expect(sidebar.classList).toContain('gl-visibility-hidden');
- expect(toggle.focus).toHaveBeenCalled();
- });
- });
-
- describe('on programmatic toggle', () => {
- it('hides the sidebar, but does not focus the toggle', () => {
- toggleSuperSidebarCollapsed(collapse, false, false);
- jest.runAllTimers();
-
- expect(sidebar.classList).toContain('gl-visibility-hidden');
- expect(toggle.focus).not.toHaveBeenCalled();
- });
- });
- });
-
- describe('expanding the sidebar', () => {
- const collapse = false;
-
- describe('on user action', () => {
- it('shows the sidebar, then focuses it', () => {
- toggleSuperSidebarCollapsed(collapse, false, true);
-
- expect(sidebar.classList).not.toContain('gl-visibility-hidden');
- expect(sidebar.focus).toHaveBeenCalled();
- });
- });
-
- describe('on programmatic toggle', () => {
- it('shows the sidebar, but does not focus it', () => {
- toggleSuperSidebarCollapsed(collapse, false, false);
-
- expect(sidebar.classList).not.toContain('gl-visibility-hidden');
- expect(sidebar.focus).not.toHaveBeenCalled();
- });
- });
- });
- });
});
describe('initSuperSidebarCollapsedState', () => {
@@ -157,40 +91,4 @@ describe('Super Sidebar Collapsed State Manager', () => {
},
);
});
-
- describe('bindSuperSidebarCollapsedEvents', () => {
- it.each`
- windowWidth | cookie | hasClass
- ${xl} | ${undefined} | ${true}
- ${sm} | ${undefined} | ${true}
- ${xl} | ${'true'} | ${false}
- ${sm} | ${'true'} | ${false}
- `(
- 'toggle click sets page class to `page-with-super-sidebar-collapsed` when windowWidth is $windowWidth and cookie value is $cookie',
- ({ windowWidth, cookie, hasClass }) => {
- setHTMLFixture(`
- <div class="page-with-super-sidebar ${cookie ? SIDEBAR_COLLAPSED_CLASS : ''}">
- <aside class="super-sidebar"></aside>
- <button class="js-super-sidebar-toggle"></button>
- </div>
- `);
- jest.spyOn(bp, 'windowWidth').mockReturnValue(windowWidth);
- getCookie.mockReturnValue(cookie);
-
- bindSuperSidebarCollapsedEvents();
-
- findToggle().click();
-
- pageHasCollapsedClass(hasClass);
-
- if (windowWidth >= xl) {
- expect(setCookie).toHaveBeenCalledWith(SIDEBAR_COLLAPSED_COOKIE, !cookie, {
- expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION,
- });
- } else {
- expect(setCookie).not.toHaveBeenCalled();
- }
- },
- );
- });
});
diff --git a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
index 8f4235cfe41..2fdab40b4bd 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
@@ -1,4 +1,5 @@
-import Vue, { nextTick } from 'vue';
+import { nextTick } from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue';
const MOCK_DATA = {
@@ -48,56 +49,37 @@ const MOCK_DATA = {
};
describe('Suggestion component', () => {
- let vm;
- let diffTable;
+ let wrapper;
- beforeEach(async () => {
- const Component = Vue.extend(SuggestionsComponent);
+ const createComponent = (props = {}) => {
+ wrapper = mountExtended(SuggestionsComponent, {
+ propsData: {
+ ...MOCK_DATA,
+ ...props,
+ },
+ });
+ };
- vm = new Component({
- propsData: MOCK_DATA,
- }).$mount();
+ const findSuggestionsContainer = () => wrapper.findByTestId('suggestions-container');
- diffTable = vm.generateDiff(0).$mount().$el;
+ beforeEach(async () => {
+ createComponent();
- jest.spyOn(vm, 'renderSuggestions').mockImplementation(() => {});
- vm.renderSuggestions();
await nextTick();
});
describe('mounted', () => {
it('renders a flash container', () => {
- expect(vm.$el.querySelector('.js-suggestions-flash')).not.toBeNull();
+ expect(wrapper.find('.js-suggestions-flash').exists()).toBe(true);
});
it('renders a container for suggestions', () => {
- expect(vm.$refs.container).not.toBeNull();
+ expect(findSuggestionsContainer().exists()).toBe(true);
});
it('renders suggestions', () => {
- expect(vm.renderSuggestions).toHaveBeenCalled();
- expect(vm.$el.innerHTML.includes('oldtest')).toBe(true);
- expect(vm.$el.innerHTML.includes('newtest')).toBe(true);
- });
- });
-
- describe('generateDiff', () => {
- it('generates a diff table', () => {
- expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull();
- });
-
- it('generates a diff table that contains contents the suggested lines', () => {
- MOCK_DATA.suggestions[0].diff_lines.forEach((line) => {
- const text = line.text.substring(1);
-
- expect(diffTable.innerHTML.includes(text)).toBe(true);
- });
- });
-
- it('generates a diff table with the correct line number for each suggested line', () => {
- const lines = diffTable.querySelectorAll('.old_line');
-
- expect(parseInt([...lines][0].innerHTML, 10)).toBe(5);
+ expect(findSuggestionsContainer().text()).toContain('oldtest');
+ expect(findSuggestionsContainer().text()).toContain('newtest');
});
});
});
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index d940c696fb3..38cbb5a1d66 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe IssuesHelper do
+ include Features::MergeRequestHelpers
+
let_it_be(:project) { create(:project) }
let_it_be_with_reload(:issue) { create(:issue, project: project) }
@@ -235,10 +237,13 @@ RSpec.describe IssuesHelper do
describe '#issue_header_actions_data' do
let(:current_user) { create(:user) }
+ let(:merge_request) { create(:merge_request, :opened, source_project: project, author: current_user) }
+ let(:issuable_sidebar_issue) { serialize_issuable_sidebar(current_user, project, merge_request) }
before do
allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:can?).and_return(true)
+ allow(helper).to receive(:issuable_sidebar).and_return(issuable_sidebar_issue)
end
it 'returns expected result' do
@@ -257,10 +262,11 @@ RSpec.describe IssuesHelper do
report_abuse_path: add_category_abuse_reports_path,
reported_user_id: issue.author.id,
reported_from_url: issue_url(issue),
- submit_as_spam_path: mark_as_spam_project_issue_path(project, issue)
+ submit_as_spam_path: mark_as_spam_project_issue_path(project, issue),
+ issuable_email_address: issuable_sidebar_issue[:create_note_email]
}
- expect(helper.issue_header_actions_data(project, issue, current_user)).to include(expected)
+ expect(helper.issue_header_actions_data(project, issue, current_user, issuable_sidebar_issue)).to include(expected)
end
end
diff --git a/spec/lib/gitlab/background_migration/update_code_suggestions_for_namespace_settings_spec.rb b/spec/lib/gitlab/background_migration/update_code_suggestions_for_namespace_settings_spec.rb
new file mode 100644
index 00000000000..3f0b4be2f71
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/update_code_suggestions_for_namespace_settings_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::UpdateCodeSuggestionsForNamespaceSettings, schema: 20230418164957, feature_category: :code_suggestions do
+ let(:namespaces_table) { table(:namespaces) }
+ let(:namespace_settings_table) { table(:namespace_settings) }
+
+ subject(:perform_migration) do
+ described_class.new(
+ start_id: 1,
+ end_id: 5,
+ batch_table: :namespace_settings,
+ batch_column: :namespace_id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection
+ ).perform
+ end
+
+ before do
+ namespaces_table.create!(id: 1, name: 'group_namespace', path: 'path-1', type: 'Group')
+ namespaces_table.create!(id: 2, name: 'subgroup', path: 'path-2', type: 'Group', parent_id: 2)
+ namespaces_table.create!(id: 3, name: 'user_namespace', path: 'path-3', type: 'User')
+ namespaces_table.create!(id: 4, name: 'group_four_namespace', path: 'path-4', type: 'Group')
+ namespaces_table.create!(id: 5, name: 'group_five_namespace', path: 'path-5', type: 'Group')
+
+ namespace_settings_table.create!(namespace_id: 1, code_suggestions: false)
+ namespace_settings_table.create!(namespace_id: 2, code_suggestions: false)
+ namespace_settings_table.create!(namespace_id: 3, code_suggestions: false)
+ namespace_settings_table.create!(namespace_id: 4, code_suggestions: false)
+ namespace_settings_table.create!(namespace_id: 5, code_suggestions: true)
+ end
+
+ it 'updates `code_suggestions` column to true for namespaces', :aggregate_failures do
+ perform_migration
+
+ expect(migrated_attribute(1)).to be_truthy
+ expect(migrated_attribute(2)).to be_truthy
+ expect(migrated_attribute(3)).to be_truthy
+ expect(migrated_attribute(4)).to be_truthy
+ expect(migrated_attribute(5)).to be_truthy
+ end
+
+ def migrated_attribute(namespace_id)
+ namespace_settings_table.find(namespace_id).code_suggestions
+ end
+end
diff --git a/spec/migrations/20230418164957_queue_update_code_suggestions_for_namespace_settings_spec.rb b/spec/migrations/20230418164957_queue_update_code_suggestions_for_namespace_settings_spec.rb
new file mode 100644
index 00000000000..4a74a400797
--- /dev/null
+++ b/spec/migrations/20230418164957_queue_update_code_suggestions_for_namespace_settings_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueUpdateCodeSuggestionsForNamespaceSettings, feature_category: :code_suggestions do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :namespace_settings,
+ column_name: :namespace_id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ }
+ end
+ end
+end
diff --git a/spec/migrations/remove_saml_provider_and_identities_non_root_group_spec.rb b/spec/migrations/remove_saml_provider_and_identities_non_root_group_spec.rb
new file mode 100644
index 00000000000..07873d0ce79
--- /dev/null
+++ b/spec/migrations/remove_saml_provider_and_identities_non_root_group_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemoveSamlProviderAndIdentitiesNonRootGroup, feature_category: :system_access do
+ let(:namespaces) { table(:namespaces) }
+ let(:saml_providers) { table(:saml_providers) }
+ let(:identities) { table(:identities) }
+ let(:root_group) do
+ namespaces.create!(name: 'root_group', path: 'foo', parent_id: nil, type: 'Group')
+ end
+
+ let(:non_root_group) do
+ namespaces.create!(name: 'non_root_group', path: 'non_root', parent_id: root_group.id, type: 'Group')
+ end
+
+ it 'removes saml_providers that belong to non-root group and related identities' do
+ provider_root_group = saml_providers.create!(
+ group_id: root_group.id,
+ sso_url: 'https://saml.example.com/adfs/ls',
+ certificate_fingerprint: '55:44:33:22:11:aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77:88:99',
+ default_membership_role: ::Gitlab::Access::GUEST,
+ enabled: true
+ )
+
+ identity_root_group = identities.create!(
+ saml_provider_id: provider_root_group.id,
+ extern_uid: "12345"
+ )
+
+ provider_non_root_group = saml_providers.create!(
+ group_id: non_root_group.id,
+ sso_url: 'https://saml.example.com/adfs/ls',
+ certificate_fingerprint: '55:44:33:22:11:aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77:88:99',
+ default_membership_role: ::Gitlab::Access::GUEST,
+ enabled: true
+ )
+
+ identity_non_root_group = identities.create!(
+ saml_provider_id: provider_non_root_group.id,
+ extern_uid: "12345"
+ )
+
+ expect { migrate! }.to change { saml_providers.count }.from(2).to(1)
+
+ expect(identities.find_by_id(identity_non_root_group.id)).to be_nil
+ expect(saml_providers.find_by_id(provider_non_root_group.id)).to be_nil
+
+ expect(identities.find_by_id(identity_root_group.id)).not_to be_nil
+ expect(saml_providers.find_by_id(provider_root_group.id)).not_to be_nil
+ end
+end
diff --git a/spec/migrations/remove_scim_token_and_scim_identity_non_root_group_spec.rb b/spec/migrations/remove_scim_token_and_scim_identity_non_root_group_spec.rb
new file mode 100644
index 00000000000..31915365c91
--- /dev/null
+++ b/spec/migrations/remove_scim_token_and_scim_identity_non_root_group_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemoveScimTokenAndScimIdentityNonRootGroup, feature_category: :system_access do
+ let(:namespaces) { table(:namespaces) }
+ let(:scim_oauth_access_tokens) { table(:scim_oauth_access_tokens) }
+ let(:scim_identities) { table(:scim_identities) }
+ let(:users) { table(:users) }
+ let(:root_group) do
+ namespaces.create!(name: 'root_group', path: 'foo', parent_id: nil, type: 'Group')
+ end
+
+ let(:non_root_group) do
+ namespaces.create!(name: 'non_root_group', path: 'non_root', parent_id: root_group.id, type: 'Group')
+ end
+
+ let(:root_group_user) do
+ users.create!(name: 'Example User', email: 'user@example.com', projects_limit: 0)
+ end
+
+ let(:non_root_group_user) do
+ users.create!(username: 'user2', email: 'user2@example.com', projects_limit: 10)
+ end
+
+ it 'removes scim_oauth_access_tokens that belong to non-root group and related scim_identities' do
+ scim_oauth_access_token_root_group = scim_oauth_access_tokens.create!(
+ group_id: root_group.id,
+ token_encrypted: Gitlab::CryptoHelper.aes256_gcm_encrypt(SecureRandom.hex(50))
+ )
+ scim_oauth_access_token_non_root_group = scim_oauth_access_tokens.create!(
+ group_id: non_root_group.id,
+ token_encrypted: Gitlab::CryptoHelper.aes256_gcm_encrypt(SecureRandom.hex(50))
+ )
+
+ scim_identity_root_group = scim_identities.create!(
+ group_id: root_group.id,
+ extern_uid: "12345",
+ user_id: root_group_user.id,
+ active: true
+ )
+
+ scim_identity_non_root_group = scim_identities.create!(
+ group_id: non_root_group.id,
+ extern_uid: "12345",
+ user_id: non_root_group_user.id,
+ active: true
+ )
+
+ expect { migrate! }.to change { scim_oauth_access_tokens.count }.from(2).to(1)
+ expect(scim_oauth_access_tokens.find_by_id(scim_oauth_access_token_non_root_group.id)).to be_nil
+ expect(scim_identities.find_by_id(scim_identity_non_root_group.id)).to be_nil
+
+ expect(scim_oauth_access_tokens.find_by_id(scim_oauth_access_token_root_group.id)).not_to be_nil
+ expect(scim_identities.find_by_id(scim_identity_root_group.id)).not_to be_nil
+ end
+end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 5169fdf504d..f3aa174a964 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -4725,9 +4725,9 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev
end
[:closed, :merged].each do |state|
- let(:state) { state }
-
context state do
+ let(:state) { state }
+
it 'does not notify' do
expect(notification_service).not_to receive(:merge_request_unmergeable)
expect(todo_service).not_to receive(:merge_request_became_unmergeable)
diff --git a/spec/requests/api/graphql/project/branches_tipping_at_commit_spec.rb b/spec/requests/api/graphql/project/branches_tipping_at_commit_spec.rb
new file mode 100644
index 00000000000..bba8977078d
--- /dev/null
+++ b/spec/requests/api/graphql/project/branches_tipping_at_commit_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.project(fullPath).tagsTippingAtCommit(commitSha)', feature_category: :source_code_management do
+ include GraphqlHelpers
+ include Presentable
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:repository) { project.repository.raw }
+ let_it_be(:current_user) { project.first_owner }
+ let_it_be(:branches_names) { %w[master not-merged-branch v1.1.0] }
+
+ let(:post_query) { post_graphql(query, current_user: current_user) }
+ let(:path) { %w[project branchesTippingAtCommit names] }
+ let(:data) { graphql_data.dig(*path) }
+
+ let(:query) do
+ graphql_query_for(
+ :project,
+ { fullPath: project.full_path },
+ query_graphql_field(:branchesTippingAtCommit, { commitSha: commit_sha }, :names)
+ )
+ end
+
+ context 'when commit exists and is tipping branches' do
+ let_it_be(:commit_sha) { repository.commit.id }
+
+ context 'with authorized user' do
+ it 'returns branches names tipping the commit' do
+ post_query
+
+ expect(data).to eq(branches_names)
+ end
+ end
+
+ context 'when user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'returns branches names tipping the commit' do
+ post_query
+
+ expect(data).to eq(nil)
+ end
+ end
+ end
+
+ context 'when commit does not exist' do
+ let(:commit_sha) { '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff4' }
+
+ it 'returns tags names tipping the commit' do
+ post_query
+
+ expect(data).to eq([])
+ end
+ end
+
+ context 'when commit exists but does not tip any branches' do
+ let(:commit_sha) { project.repository.commits(nil, { limit: 4 }).commits[2].id }
+
+ it 'returns tags names tipping the commit' do
+ post_query
+
+ expect(data).to eq([])
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/tags_tipping_at_commit_spec.rb b/spec/requests/api/graphql/project/tags_tipping_at_commit_spec.rb
new file mode 100644
index 00000000000..a5e26482a9e
--- /dev/null
+++ b/spec/requests/api/graphql/project/tags_tipping_at_commit_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.project(fullPath).tagsTippingAtCommit(commitSha)', feature_category: :source_code_management do
+ include GraphqlHelpers
+ include Presentable
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:repository) { project.repository.raw }
+ let_it_be(:current_user) { project.first_owner }
+ let_it_be(:tag_name) { 'v1.0.0' }
+
+ let(:post_query) { post_graphql(query, current_user: current_user) }
+ let(:path) { %w[project tagsTippingAtCommit names] }
+ let(:data) { graphql_data.dig(*path) }
+
+ let(:query) do
+ graphql_query_for(
+ :project,
+ { fullPath: project.full_path },
+ query_graphql_field(:tagsTippingAtCommit, { commitSha: commit_sha }, :names)
+ )
+ end
+
+ context 'when commit exists and is tipping tags' do
+ let(:commit_sha) { repository.find_tag(tag_name).dereferenced_target.sha }
+
+ context 'with authorized user' do
+ it 'returns tags names tipping the commit' do
+ post_query
+
+ expect(data).to eq([tag_name])
+ end
+ end
+
+ context 'when user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'returns tags names tipping the commit' do
+ post_query
+
+ expect(data).to eq(nil)
+ end
+ end
+ end
+
+ context 'when commit does not exist' do
+ let(:commit_sha) { '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff4' }
+
+ it 'returns tags names tipping the commit' do
+ post_query
+
+ expect(data).to eq([])
+ end
+ end
+
+ context 'when commit exists but does not tip any tags' do
+ let(:commit_sha) { project.repository.commits(nil, { limit: 4 }).commits[2].id }
+
+ it 'returns tags names tipping the commit' do
+ post_query
+
+ expect(data).to eq([])
+ end
+ end
+end
diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml
index 490d32bc5e9..22a61adfae2 100644
--- a/spec/requests/api/project_attributes.yml
+++ b/spec/requests/api/project_attributes.yml
@@ -165,6 +165,7 @@ project_setting:
- pages_unique_domain_enabled
- pages_unique_domain
- runner_registration_enabled
+ - product_analytics_instrumentation_key
build_service_desk_setting: # service_desk_setting
unexposed_attributes: