diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js
index 6da141cb19a..3ac3a3a3611 100644
--- a/app/assets/javascripts/whats_new/index.js
+++ b/app/assets/javascripts/whats_new/index.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import { mapState } from 'vuex';
import App from './components/app.vue';
import store from './store';
-import { getStorageKey, setNotification } from './utils/notification';
+import { getVersionDigest, setNotification } from './utils/notification';
let whatsNewApp;
@@ -27,9 +27,7 @@ export default (el) => {
render(createElement) {
return createElement('app', {
props: {
- storageKey: getStorageKey(el),
- versions: JSON.parse(el.getAttribute('data-versions')),
- gitlabDotCom: el.getAttribute('data-gitlab-dot-com'),
+ versionDigest: getVersionDigest(el),
},
});
},
diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js
index 4b3cfa55977..1dc92ea2606 100644
--- a/app/assets/javascripts/whats_new/store/actions.js
+++ b/app/assets/javascripts/whats_new/store/actions.js
@@ -1,19 +1,20 @@
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { STORAGE_KEY } from '../utils/notification';
import * as types from './mutation_types';
export default {
closeDrawer({ commit }) {
commit(types.CLOSE_DRAWER);
},
- openDrawer({ commit }, storageKey) {
+ openDrawer({ commit }, versionDigest) {
commit(types.OPEN_DRAWER);
- if (storageKey) {
- localStorage.setItem(storageKey, JSON.stringify(false));
+ if (versionDigest) {
+ localStorage.setItem(STORAGE_KEY, versionDigest);
}
},
- fetchItems({ commit, state }, { page, version } = { page: null, version: null }) {
+ fetchItems({ commit, state }, { page } = { page: null }) {
if (state.fetching) {
return false;
}
@@ -24,7 +25,6 @@ export default {
.get('/-/whats_new', {
params: {
page,
- version,
},
})
.then(({ data, headers }) => {
diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js
index 52ca8058d1c..3d4326c4b3a 100644
--- a/app/assets/javascripts/whats_new/utils/notification.js
+++ b/app/assets/javascripts/whats_new/utils/notification.js
@@ -1,11 +1,18 @@
-export const getStorageKey = (appEl) => appEl.getAttribute('data-storage-key');
+export const STORAGE_KEY = 'display-whats-new-notification';
+
+export const getVersionDigest = (appEl) => appEl.getAttribute('data-version-digest');
export const setNotification = (appEl) => {
- const storageKey = getStorageKey(appEl);
+ const versionDigest = getVersionDigest(appEl);
const notificationEl = document.querySelector('.header-help');
let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count');
- if (JSON.parse(localStorage.getItem(storageKey)) === false) {
+ const legacyStorageKey = 'display-whats-new-notification-13.10';
+ const localStoragePairs = [
+ [legacyStorageKey, false],
+ [STORAGE_KEY, versionDigest],
+ ];
+ if (localStoragePairs.some((pair) => localStorage.getItem(pair[0]) === pair[1].toString())) {
notificationEl.classList.remove('with-notifications');
if (notificationCountEl) {
notificationCountEl.parentElement.removeChild(notificationCountEl);
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index a005daef2e4..2f3cf889549 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -148,7 +148,19 @@
}
.gl-build-content {
- @include build-content();
+ display: inline-block;
+ padding: 8px 10px 9px;
+ width: 100%;
+ border: 1px solid var(--border-color, $border-color);
+ border-radius: 30px;
+ background-color: var(--white, $white);
+
+ &:hover,
+ &:focus {
+ background-color: var(--gray-50, $gray-50);
+ border: 1px solid $dropdown-toggle-active-border-color;
+ color: var(--gl-text-color, $gl-text-color);
+ }
}
.gl-ci-action-icon-container {
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index e8e681ce649..7bfcda67aa2 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -5,19 +5,23 @@ module CreatesCommit
include Gitlab::Utils::StrongMemoize
# rubocop:disable Gitlab/ModuleWithInstanceVariables
- def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil)
- if user_access(@project).can_push_to_branch?(branch_name_or_ref)
- @project_to_commit_into = @project
+ def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil, target_project: nil)
+ target_project ||= @project
+
+ if user_access(target_project).can_push_to_branch?(branch_name_or_ref)
+ @project_to_commit_into = target_project
@branch_name ||= @ref
else
- @project_to_commit_into = current_user.fork_of(@project)
+ @project_to_commit_into = current_user.fork_of(target_project)
@branch_name ||= @project_to_commit_into.repository.next_branch('patch')
end
@start_branch ||= @ref || @branch_name
+ start_project = Feature.enabled?(:pick_into_project, @project, default_enabled: :yaml) ? @project_to_commit_into : @project
+
commit_params = @commit_params.merge(
- start_project: @project,
+ start_project: start_project,
start_branch: @start_branch,
branch_name: @branch_name
)
@@ -27,7 +31,7 @@ module CreatesCommit
if result[:status] == :success
update_flash_notice(success_notice)
- success_path = final_success_path(success_path)
+ success_path = final_success_path(success_path, target_project)
respond_to do |format|
format.html { redirect_to success_path }
@@ -79,9 +83,9 @@ module CreatesCommit
end
end
- def final_success_path(success_path)
+ def final_success_path(success_path, target_project)
if create_merge_request?
- merge_request_exists? ? existing_merge_request_path : new_merge_request_path
+ merge_request_exists? ? existing_merge_request_path : new_merge_request_path(target_project)
else
success_path = success_path.call if success_path.respond_to?(:call)
@@ -90,12 +94,12 @@ module CreatesCommit
end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
- def new_merge_request_path
+ def new_merge_request_path(target_project)
project_new_merge_request_path(
@project_to_commit_into,
merge_request: {
source_project_id: @project_to_commit_into.id,
- target_project_id: @project.id,
+ target_project_id: target_project.id,
source_branch: @branch_name,
target_branch: @start_branch
}
diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb
index 826fae834fa..4ea07c814ef 100644
--- a/app/controllers/concerns/renders_commits.rb
+++ b/app/controllers/concerns/renders_commits.rb
@@ -17,12 +17,13 @@ module RendersCommits
def set_commits_for_rendering(commits, commits_count: nil)
@total_commit_count = commits_count || commits.size
limited, @hidden_commit_count = limited_commits(commits, @total_commit_count)
- commits.each(&:lazy_author) # preload authors
prepare_commits_for_rendering(limited)
end
# rubocop: enable Gitlab/ModuleWithInstanceVariables
def prepare_commits_for_rendering(commits)
+ commits.each(&:lazy_author) # preload commits' authors
+
Banzai::CommitRenderer.render(commits, @project, current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
commits
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index fdf66340cbb..1e65974a3cd 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -114,7 +114,7 @@ class Projects::CommitController < Projects::ApplicationController
@branch_name = create_new_branch? ? @commit.revert_branch_name : @start_branch
create_commit(Commits::RevertService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully reverted.",
- success_path: -> { successful_change_path }, failure_path: failed_change_path)
+ success_path: -> { successful_change_path(@project) }, failure_path: failed_change_path)
end
def cherry_pick
@@ -122,10 +122,15 @@ class Projects::CommitController < Projects::ApplicationController
return render_404 if @start_branch.blank?
+ target_project = find_cherry_pick_target_project
+ return render_404 unless target_project
+
@branch_name = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch
create_commit(Commits::CherryPickService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully cherry-picked into #{@branch_name}.",
- success_path: -> { successful_change_path }, failure_path: failed_change_path)
+ success_path: -> { successful_change_path(target_project) },
+ failure_path: failed_change_path,
+ target_project: target_project)
end
private
@@ -138,8 +143,8 @@ class Projects::CommitController < Projects::ApplicationController
params[:create_merge_request].present? || !can?(current_user, :push_code, @project)
end
- def successful_change_path
- referenced_merge_request_url || project_commits_url(@project, @branch_name)
+ def successful_change_path(target_project)
+ referenced_merge_request_url || project_commits_url(target_project, @branch_name)
end
def failed_change_path
@@ -218,4 +223,14 @@ class Projects::CommitController < Projects::ApplicationController
@start_branch = params[:start_branch]
@commit_params = { commit: @commit }
end
+
+ def find_cherry_pick_target_project
+ return @project if params[:target_project_id].blank?
+ return @project unless Feature.enabled?(:pick_into_project, @project, default_enabled: :yaml)
+
+ MergeRequestTargetProjectFinder
+ .new(current_user: current_user, source_project: @project, project_feature: :repository)
+ .execute
+ .find_by_id(params[:target_project_id])
+ end
end
diff --git a/app/controllers/whats_new_controller.rb b/app/controllers/whats_new_controller.rb
index 12a52f30bd0..e24b0bbc7bb 100644
--- a/app/controllers/whats_new_controller.rb
+++ b/app/controllers/whats_new_controller.rb
@@ -5,7 +5,7 @@ class WhatsNewController < ApplicationController
skip_before_action :authenticate_user!
- before_action :check_valid_page_param, :set_pagination_headers, unless: -> { has_version_param? }
+ before_action :check_valid_page_param, :set_pagination_headers
feature_category :navigation
@@ -29,19 +29,11 @@ class WhatsNewController < ApplicationController
def highlights
strong_memoize(:highlights) do
- if has_version_param?
- ReleaseHighlight.for_version(version: params[:version])
- else
- ReleaseHighlight.paginated(page: current_page)
- end
+ ReleaseHighlight.paginated(page: current_page)
end
end
def set_pagination_headers
response.set_header('X-Next-Page', highlights.next_page)
end
-
- def has_version_param?
- params[:version].present?
- end
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 652ba9950bc..e7a81eb5629 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -139,7 +139,7 @@ module CommitsHelper
def cherry_pick_projects_data(project)
return [] unless Feature.enabled?(:pick_into_project, project, default_enabled: :yaml)
- target_projects(project).map do |project|
+ [project, project.forked_from_project].compact.map do |project|
{
id: project.id.to_s,
name: project.full_path,
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 8cf5cd49322..a4521541bf9 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -56,6 +56,33 @@ module NamespacesHelper
namespaces_options(selected, **options)
end
+ def cascading_namespace_settings_enabled?
+ NamespaceSetting.cascading_settings_feature_enabled?
+ end
+
+ def cascading_namespace_settings_popover_data(attribute, group, settings_path_helper)
+ locked_by_ancestor = group.namespace_settings.public_send("#{attribute}_locked_by_ancestor?") # rubocop:disable GitlabSecurity/PublicSend
+
+ popover_data = {
+ locked_by_application_setting: group.namespace_settings.public_send("#{attribute}_locked_by_application_setting?"), # rubocop:disable GitlabSecurity/PublicSend
+ locked_by_ancestor: locked_by_ancestor
+ }
+
+ if locked_by_ancestor
+ ancestor_namespace = group.namespace_settings.public_send("#{attribute}_locked_ancestor").namespace # rubocop:disable GitlabSecurity/PublicSend
+
+ popover_data[:ancestor_namespace] = {
+ full_name: ancestor_namespace.full_name,
+ path: settings_path_helper.call(ancestor_namespace)
+ }
+ end
+
+ {
+ popover_data: popover_data.to_json,
+ testid: 'cascading-settings-lock-icon'
+ }
+ end
+
private
# Many importers create a temporary Group, so use the real
diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb
index bbf5bde5904..23ed2fc987c 100644
--- a/app/helpers/whats_new_helper.rb
+++ b/app/helpers/whats_new_helper.rb
@@ -5,15 +5,7 @@ module WhatsNewHelper
ReleaseHighlight.most_recent_item_count
end
- def whats_new_storage_key
- most_recent_version = ReleaseHighlight.versions&.first
-
- return unless most_recent_version
-
- ['display-whats-new-notification', most_recent_version].join('-')
- end
-
- def whats_new_versions
- ReleaseHighlight.versions
+ def whats_new_version_digest
+ ReleaseHighlight.most_recent_version_digest
end
end
diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb
index 1efba6380e9..98d9899a349 100644
--- a/app/models/release_highlight.rb
+++ b/app/models/release_highlight.rb
@@ -3,17 +3,6 @@
class ReleaseHighlight
CACHE_DURATION = 1.hour
FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml')
- RELEASE_VERSIONS_IN_A_YEAR = 12
-
- def self.for_version(version:)
- index = self.versions.index(version)
-
- return if index.nil?
-
- page = index + 1
-
- self.paginated(page: page)
- end
def self.paginated(page: 1)
key = self.cache_key("items:page-#{page}")
@@ -82,15 +71,15 @@ class ReleaseHighlight
end
end
- def self.versions
- key = self.cache_key('versions')
+ def self.most_recent_version_digest
+ key = self.cache_key('most_recent_version_digest')
Gitlab::ProcessMemoryCache.cache_backend.fetch(key, expires_in: CACHE_DURATION) do
- versions = self.file_paths.first(RELEASE_VERSIONS_IN_A_YEAR).map do |path|
- /\d*\_(\d*\_\d*)\.yml$/.match(path).captures[0].gsub(/0(?=\d)/, "").tr("_", ".")
- end
+ version = self.paginated&.items&.first&.[]('release')&.to_s
- versions.uniq
+ next if version.nil?
+
+ Digest::SHA256.hexdigest(version)
end
end
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index a14d342bc14..9dce33bf037 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -2,10 +2,9 @@
- page_title _("Broadcast Messages")
%h3.page-title
- Broadcast Messages
+ = _('Broadcast Messages')
%p.light
- Broadcast messages are displayed for every user and can be used to notify
- users about scheduled maintenance, recent upgrades and more.
+ = _('Broadcast messages are displayed for every user and can be used to notify users about scheduled maintenance, recent upgrades and more.')
= render 'form'
@@ -15,12 +14,12 @@
%table.table.table-responsive
%thead
%tr
- %th Status
- %th Preview
- %th Starts
- %th Ends
- %th Target Path
- %th Type
+ %th= _('Status')
+ %th= _('Preview')
+ %th= _('Starts')
+ %th= _('Ends')
+ %th= _(' Target Path')
+ %th= _(' Type')
%th
%tbody
- @broadcast_messages.each do |message|
@@ -38,7 +37,7 @@
%td
= message.broadcast_type.capitalize
%td.gl-white-space-nowrap.gl-display-flex
- = link_to sprite_icon('pencil-square', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn btn-icon gl-button'
- = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-icon gl-button btn-danger ml-2'
+ = link_to sprite_icon('pencil-square', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: _('Edit'), class: 'btn btn-icon gl-button'
+ = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: _('Remove'), class: 'js-remove-tr btn btn-icon gl-button btn-danger ml-2'
= paginate @broadcast_messages, theme: 'gitlab'
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index 57c0801074b..90a49e4bbe3 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -18,11 +18,11 @@
= nav_link(page: [dashboard_projects_path, root_path]) do
= link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do
= _("Your projects")
- %span.badge.badge-pill= limited_counter_with_delimiter(@total_user_projects_count)
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(@total_user_projects_count)
= nav_link(page: starred_dashboard_projects_path) do
= link_to starred_dashboard_projects_path, data: {placement: 'right'} do
= _("Starred projects")
- %span.badge.badge-pill= limited_counter_with_delimiter(@total_starred_projects_count)
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(@total_starred_projects_count)
= nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do
= link_to explore_root_path, data: {placement: 'right'} do
= _("Explore projects")
diff --git a/app/views/devise/mailer/_confirmation_instructions_account.html.haml b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
index 27ef586d90f..9d469ff6e7b 100644
--- a/app/views/devise/mailer/_confirmation_instructions_account.html.haml
+++ b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
@@ -2,15 +2,15 @@
- if @resource.unconfirmed_email.present? || !@resource.created_recently?
#content
= email_default_heading(@resource.unconfirmed_email || @resource.email)
- %p Click the link below to confirm your email address.
+ %p= _('Click the link below to confirm your email address.')
#cta
- = link_to 'Confirm your email address', confirmation_link
+ = link_to _('Confirm your email address'), confirmation_link
- else
#content
- if Gitlab.com?
- = email_default_heading('Thanks for signing up to GitLab!')
+ = email_default_heading(_('Thanks for signing up to GitLab!'))
- else
- = email_default_heading("Welcome, #{@resource.name}!")
- %p To get started, click the link below to confirm your account.
+ = email_default_heading(_("Welcome, %{name}!") % { name: @resource.name })
+ %p= _("To get started, click the link below to confirm your account.")
#cta
- = link_to 'Confirm your account', confirmation_link
+ = link_to _('Confirm your account'), confirmation_link
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 71e61436410..ee9eed7e6f6 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -3,6 +3,7 @@
- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
+= render 'shared/namespaces/cascading_settings/lock_popovers'
%section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded') }
.settings-header
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 3e29e42126e..fcfe70bd694 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -8,27 +8,27 @@
= render 'shared/allow_request_access', form: f
.form-group.gl-mb-3
- .form-check
- = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'form-check-input'
- = f.label :share_with_group_lock, class: 'form-check-label' do
- %span.d-block
+ .gl-form-checkbox.custom-control.custom-checkbox
+ = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'custom-control-input'
+ = f.label :share_with_group_lock, class: 'custom-control-label' do
+ %span
- group_link = link_to @group.name, group_path(@group)
= s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: group_link }
- %span.js-descr.text-muted= share_with_group_lock_help_text(@group)
+ %p.js-descr.help-text= share_with_group_lock_help_text(@group)
.form-group.gl-mb-3
- .form-check
- = f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'form-check-input'
- = f.label :emails_disabled, class: 'form-check-label' do
- %span.d-block= s_('GroupSettings|Disable email notifications')
- %span.text-muted= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.')
+ .gl-form-checkbox.custom-control.custom-checkbox
+ = f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'custom-control-input'
+ = f.label :emails_disabled, class: 'custom-control-label' do
+ %span= s_('GroupSettings|Disable email notifications')
+ %p.help-text= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.')
.form-group.gl-mb-3
- .form-check
- = f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'form-check-input'
- = f.label :mentions_disabled, class: 'form-check-label' do
- %span.d-block= s_('GroupSettings|Disable group mentions')
- %span.text-muted= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
+ .gl-form-checkbox.custom-control.custom-checkbox
+ = f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'custom-control-input'
+ = f.label :mentions_disabled, class: 'custom-control-label' do
+ %span= s_('GroupSettings|Disable group mentions')
+ %p.help-text= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
= render 'groups/settings/project_access_token_creation', f: f, group: @group
= render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group
diff --git a/app/views/groups/settings/_project_access_token_creation.html.haml b/app/views/groups/settings/_project_access_token_creation.html.haml
index ac0ebfbd7f5..8be17c6cc30 100644
--- a/app/views/groups/settings/_project_access_token_creation.html.haml
+++ b/app/views/groups/settings/_project_access_token_creation.html.haml
@@ -1,10 +1,10 @@
- return unless render_setting_to_allow_project_access_token_creation?(group)
.form-group.gl-mb-3
- .form-check
- = f.check_box :resource_access_token_creation_allowed, checked: group.namespace_settings.resource_access_token_creation_allowed?, class: 'form-check-input', data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' }
- = f.label :resource_access_token_creation_allowed, class: 'form-check-label' do
- %span.gl-display-block= s_('GroupSettings|Allow project access token creation')
+ .gl-form-checkbox.custom-control.custom-checkbox
+ = f.check_box :resource_access_token_creation_allowed, checked: group.namespace_settings.resource_access_token_creation_allowed?, class: 'custom-control-input', data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' }
+ = f.label :resource_access_token_creation_allowed, class: 'custom-control-label' do
+ %span= s_('GroupSettings|Allow project access token creation')
- project_access_tokens_link = help_page_path('user/project/settings/project_access_tokens')
- link_start = '
'.html_safe % { url: project_access_tokens_link }
- %span.text-muted= s_('GroupSettings|Users can create %{link_start}project access tokens%{link_end} for projects in this group.').html_safe % { link_start: link_start, link_end: ''.html_safe }
+ %p.help-text= s_('GroupSettings|Users can create %{link_start}project access tokens%{link_end} for projects in this group.').html_safe % { link_start: link_start, link_end: ''.html_safe }
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 7203919bc85..3225dad5d57 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -120,7 +120,7 @@
= sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right')
= sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left')
-#whats-new-app{ data: { storage_key: whats_new_storage_key, versions: whats_new_versions, gitlab_dot_com: Gitlab.dev_env_org_or_com? } }
+#whats-new-app{ data: { version_digest: whats_new_version_digest } }
- if can?(current_user, :update_user_status, current_user)
.js-set-status-modal-wrapper{ data: user_status_data }
diff --git a/app/views/layouts/header/_whats_new_dropdown_item.html.haml b/app/views/layouts/header/_whats_new_dropdown_item.html.haml
index f79b741ced0..61fe2f1e711 100644
--- a/app/views/layouts/header/_whats_new_dropdown_item.html.haml
+++ b/app/views/layouts/header/_whats_new_dropdown_item.html.haml
@@ -1,5 +1,5 @@
%li
- %button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', data: { storage_key: whats_new_storage_key }, class: 'gl-display-flex!' }
+ %button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', class: 'gl-display-flex!' }
= _("What's new")
%span.js-whats-new-notification-count.whats-new-notification-count
= whats_new_most_recent_release_items_count
diff --git a/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml b/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml
new file mode 100644
index 00000000000..1e9aa4ec5ff
--- /dev/null
+++ b/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml
@@ -0,0 +1,14 @@
+- attribute = local_assigns.fetch(:attribute, nil)
+- group = local_assigns.fetch(:group, nil)
+- form = local_assigns.fetch(:form, nil)
+
+- return unless attribute && group && form && cascading_namespace_settings_enabled?
+- return if group.namespace_settings.public_send("#{attribute}_locked?")
+
+- lock_attribute = "lock_#{attribute}"
+
+.gl-form-checkbox.custom-control.custom-checkbox
+ = form.check_box lock_attribute, checked: group.namespace_settings.public_send(lock_attribute), class: 'custom-control-input', data: { testid: 'enforce-for-all-subgroups-checkbox' }
+ = form.label lock_attribute, class: 'custom-control-label' do
+ %span= s_('CascadingSettings|Enforce for all subgroups')
+ %p.help-text= s_('CascadingSettings|Subgroups cannot change this setting.')
diff --git a/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml b/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml
new file mode 100644
index 00000000000..91458bf180b
--- /dev/null
+++ b/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml
@@ -0,0 +1 @@
+.js-cascading-settings-lock-popovers
diff --git a/app/views/shared/namespaces/cascading_settings/_setting_label.html.haml b/app/views/shared/namespaces/cascading_settings/_setting_label.html.haml
new file mode 100644
index 00000000000..6596ce2bc73
--- /dev/null
+++ b/app/views/shared/namespaces/cascading_settings/_setting_label.html.haml
@@ -0,0 +1,21 @@
+- attribute = local_assigns.fetch(:attribute, nil)
+- group = local_assigns.fetch(:group, nil)
+- form = local_assigns.fetch(:form, nil)
+- settings_path_helper = local_assigns.fetch(:settings_path_helper, nil)
+- help_text = local_assigns.fetch(:help_text, nil)
+
+- return unless attribute && group && form && settings_path_helper
+
+- setting_locked = group.namespace_settings.public_send("#{attribute}_locked?")
+
+= form.label attribute, class: 'custom-control-label', aria: { disabled: setting_locked } do
+ %span.position-relative.gl-pr-6.gl-display-inline-flex
+ = yield
+ - if setting_locked
+ %button.position-absolute.gl-top-3.gl-right-0.gl-translate-y-n50.gl-cursor-default.btn.btn-default.btn-sm.gl-button.btn-default-tertiary.js-cascading-settings-lock-popover-target{ class: 'gl-p-1! gl-text-gray-600! gl-bg-transparent!',
+ type: 'button',
+ data: cascading_namespace_settings_popover_data(attribute, group, settings_path_helper) }
+ = sprite_icon('lock', size: 16)
+ - if help_text
+ %p.help-text
+ = help_text
diff --git a/changelogs/unreleased/327006-fix-search-commits-n-plus-1.yml b/changelogs/unreleased/327006-fix-search-commits-n-plus-1.yml
new file mode 100644
index 00000000000..b20f054370a
--- /dev/null
+++ b/changelogs/unreleased/327006-fix-search-commits-n-plus-1.yml
@@ -0,0 +1,5 @@
+---
+title: Fix N+1 for searching commits
+merge_request: 58867
+author:
+type: performance
diff --git a/changelogs/unreleased/Externalise-strings-in-broadcast_messages-index-html-haml.yml b/changelogs/unreleased/Externalise-strings-in-broadcast_messages-index-html-haml.yml
new file mode 100644
index 00000000000..0ce42710333
--- /dev/null
+++ b/changelogs/unreleased/Externalise-strings-in-broadcast_messages-index-html-haml.yml
@@ -0,0 +1,5 @@
+---
+title: Externalize strings in broadcast_messages/index.html.haml
+merge_request: 58146
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/Externalise-strings-in_confirmation_instructions_account-html-haml.yml b/changelogs/unreleased/Externalise-strings-in_confirmation_instructions_account-html-haml.yml
new file mode 100644
index 00000000000..a0db84bcfe8
--- /dev/null
+++ b/changelogs/unreleased/Externalise-strings-in_confirmation_instructions_account-html-haml.yml
@@ -0,0 +1,5 @@
+---
+title: Externalize strings in _confirmation_instructions_account.html.haml
+merge_request: 58214
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/gl-badge-dashboard.yml b/changelogs/unreleased/gl-badge-dashboard.yml
new file mode 100644
index 00000000000..f5087769429
--- /dev/null
+++ b/changelogs/unreleased/gl-badge-dashboard.yml
@@ -0,0 +1,5 @@
+---
+title: Add gl-badge for badges in dashboard nav
+merge_request: 57936
+author: Yogi (@yo)
+type: changed
diff --git a/doc/api/packages/go_proxy.md b/doc/api/packages/go_proxy.md
new file mode 100644
index 00000000000..2f81435db42
--- /dev/null
+++ b/doc/api/packages/go_proxy.md
@@ -0,0 +1,133 @@
+---
+stage: Package
+group: Package
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# Go Proxy API
+
+This is the API documentation for [Go Packages](../../user/packages/go_proxy/index.md).
+This API is behind a feature flag that is disabled by default. GitLab administrators with access to
+the GitLab Rails console can [enable](../../administration/feature_flags.md)
+this API for your GitLab instance.
+
+WARNING:
+This API is used by the [Go client](https://maven.apache.org/)
+and is generally not meant for manual consumption.
+
+For instructions on how to work with the Go Proxy, see the [Go Proxy package documentation](../../user/packages/go_proxy/index.md).
+
+NOTE:
+These endpoints do not adhere to the standard API authentication methods.
+See the [Go Proxy package documentation](../../user/packages/go_proxy/index.md)
+for details on which headers and token types are supported.
+
+## List
+
+> Introduced in GitLab 13.1.
+
+Get all tagged versions for a given Go module:
+
+```plaintext
+GET projects/:id/packages/go/:module_name/@v/list
+```
+
+| Attribute | Type | Required | Description |
+| -------------- | ------ | -------- | ----------- |
+| `id` | string | yes | The project ID or full path of a project. |
+| `module_name` | string | yes | The name of the Go module. |
+
+```shell
+curl --header "Private-Token:
" "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/list"
+```
+
+Example output:
+
+```shell
+"v1.0.0\nv1.0.1\nv1.3.8\n2.0.0\n2.1.0\n3.0.0"
+```
+
+## Version metadata
+
+> Introduced in GitLab 13.1.
+
+Get all tagged versions for a given Go module:
+
+```plaintext
+GET projects/:id/packages/go/:module_name/@v/:module_version.info
+```
+
+| Attribute | Type | Required | Description |
+| ----------------- | ------ | -------- | ----------- |
+| `id` | string | yes | The project ID or full path of a project. |
+| `module_name` | string | yes | The name of the Go module. |
+| `module_version` | string | yes | The version of the Go module. |
+
+```shell
+curl --header "Private-Token: " "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.info"
+```
+
+Example output:
+
+```json
+{
+ "Version": "v1.0.0",
+ "Time": "1617822312 -0600"
+}
+```
+
+## Download module file
+
+> Introduced in GitLab 13.1.
+
+Fetch the `.mod` module file:
+
+```plaintext
+GET projects/:id/packages/go/:module_name/@v/:module_version.mod
+```
+
+| Attribute | Type | Required | Description |
+| ----------------- | ------ | -------- | ----------- |
+| `id` | string | yes | The project ID or full path of a project. |
+| `module_name` | string | yes | The name of the Go module. |
+| `module_version` | string | yes | The version of the Go module. |
+
+```shell
+curl --header "Private-Token: " "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.mod"
+```
+
+Write to a file:
+
+```shell
+curl --header "Private-Token: " "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.mod" >> foo.mod
+```
+
+This writes to `foo.mod` in the current directory.
+
+## Download module source
+
+> Introduced in GitLab 13.1.
+
+Fetch the `.zip` of the module source:
+
+```plaintext
+GET projects/:id/packages/go/:module_name/@v/:module_version.zip
+```
+
+| Attribute | Type | Required | Description |
+| ----------------- | ------ | -------- | ----------- |
+| `id` | string | yes | The project ID or full path of a project. |
+| `module_name` | string | yes | The name of the Go module. |
+| `module_version` | string | yes | The version of the Go module. |
+
+```shell
+curl --header "Private-Token: " "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.zip"
+```
+
+Write to a file:
+
+```shell
+curl --header "Private-Token: " "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.zip" >> foo.zip
+```
+
+This writes to `foo.zip` in the current directory.
diff --git a/doc/development/snowplow.md b/doc/development/snowplow.md
index 6803737cd79..80bcad9e244 100644
--- a/doc/development/snowplow.md
+++ b/doc/development/snowplow.md
@@ -110,21 +110,66 @@ The current method provides several attributes that are sent on each click event
| property | text | false | Any additional property of the element, or object being acted on. |
| value | decimal | false | Describes a numeric value or something directly related to the event. This could be the value of an input (e.g. `10` when clicking `internal` visibility). |
+### Examples
+
+| category* | label | action | property** | value |
+|-------------|------------------|-----------------------|----------|:-----:|
+| [root:index] | main_navigation | click_navigation_link | `[link_label]` | - |
+| [groups:boards:show] | toggle_swimlanes | click_toggle_button | - | `[is_active]` |
+| [projects:registry:index] | registry_delete | click_button | - | - |
+| [projects:registry:index] | registry_delete | confirm_deletion | - | - |
+| [projects:blob:show] | congratulate_first_pipeline | click_button | `[human_access]` | - |
+| [projects:clusters:new] | chart_options | generate_link | `[chart_link]` | - |
+| [projects:clusters:new] | chart_options | click_add_label_button | `[label_id]` | - |
+
+_* It's ok to omit the category, and use the default._
+_** Property is usually the best place for variable strings._
+
+### Reference SQL
+
+#### Last 20 `reply_comment_button` events
+
+```sql
+SELECT
+ event_id,
+ v_tracker,
+ event_label,
+ event_action,
+ event_property,
+ event_value,
+ event_category,
+ contexts
+FROM legacy.snowplow_structured_events_all
+WHERE
+ event_label = 'reply_comment_button'
+ AND event_action = 'click_button'
+ -- AND event_category = 'projects:issues:show'
+ -- AND event_value = 1
+ORDER BY collector_tstamp DESC
+LIMIT 20
+```
+
### Web-specific parameters
Snowplow JS adds many [web-specific parameters](https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/snowplow-tracker-protocol/#Web-specific_parameters) to all web events by default.
## Implementing Snowplow JS (Frontend) tracking
-GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://github.com/snowplow/snowplow/wiki/javascript-tracker) for tracking custom events. There are a few ways to use tracking, but each generally requires at minimum, a `category` and an `action`. Additional data can be provided that adheres to our [Structured event taxonomy](#structured-event-taxonomy).
+GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://github.com/snowplow/snowplow/wiki/javascript-tracker) for tracking custom events. The simplest way to use it is to add `data-` attributes to clickable elements and dropdowns. There is also a Vue mixin (exposing a `track` method), and the static method `Tracking.event`. Each of these requires at minimum a `category` and an `action`. Additional data can be provided that adheres to our [Structured event taxonomy](#structured-event-taxonomy).
| field | type | default value | description |
|:-----------|:-------|:---------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `category` | string | `document.body.dataset.page` | Page or subsection of a page that events are being captured within. |
-| `action` | string | 'generic' | Action the user is taking. Clicks should be `click` and activations should be `activate`, so for example, focusing a form field would be `activate_form_input`, and clicking a button would be `click_button`. |
+| `action` | string | generic | Action the user is taking. Clicks should be `click` and activations should be `activate`, so for example, focusing a form field would be `activate_form_input`, and clicking a button would be `click_button`. |
| `data` | object | `{}` | Additional data such as `label`, `property`, `value`, and `context` as described in our [Structured event taxonomy](#structured-event-taxonomy). |
-### Tracking in HAML (or Vue Templates)
+### Usage recommendations
+
+- Use [data attributes](#tracking-with-data-attributes) on HTML elements that emits either the `click`, `show.bs.dropdown`, or `hide.bs.dropdown` events.
+- Use the [Vue mixin](#tracking-within-vue-components) when tracking custom events, or if the supported events for data attributes are not propagating.
+- Use the [Tracking class directly](#tracking-in-raw-javascript) when tracking on raw JS files.
+
+### Tracking with data attributes
When working within HAML (or Vue templates) we can add `data-track-*` attributes to elements of interest. All elements that have a `data-track-action` attribute automatically have event tracking bound on clicks.
@@ -142,7 +187,7 @@ Below is an example of `data-track-*` attributes assigned to a button:
/>
```
-Event listeners are bound at the document level to handle click events on or within elements with these data attributes. This allows them to be properly handled on re-rendering and changes to the DOM. Note that because of the way these events are bound, click events should not be stopped from propagating up the DOM tree. If for any reason click events are being stopped from propagating, you need to implement your own listeners and follow the instructions in [Tracking in raw JavaScript](#tracking-in-raw-javascript).
+Event listeners are bound at the document level to handle click events on or within elements with these data attributes. This allows them to be properly handled on re-rendering and changes to the DOM. Note that because of the way these events are bound, click events should not be stopped from propagating up the DOM tree. If for any reason click events are being stopped from propagating, you need to implement your own listeners and follow the instructions in [Tracking within Vue components](#tracking-within-vue-components) or [Tracking in raw JavaScript](#tracking-in-raw-javascript).
Below is a list of supported `data-track-*` attributes:
@@ -154,16 +199,29 @@ Below is a list of supported `data-track-*` attributes:
| `data-track-value` | false | The `value` as described in our [Structured event taxonomy](#structured-event-taxonomy). If omitted, this is the element's `value` property or an empty string. For checkboxes, the default value is the element's checked attribute or `false` when unchecked. |
| `data-track-context` | false | The `context` as described in our [Structured event taxonomy](#structured-event-taxonomy). |
+#### Available helpers
+
+```ruby
+tracking_attrs(label, action, property) # { data: { track_label... } }
+
+%button{ **tracking_attrs('main_navigation', 'click_button', 'navigation') }
+```
+
#### Caveats
When using the GitLab helper method [`nav_link`](https://gitlab.com/gitlab-org/gitlab/-/blob/898b286de322e5df6a38d257b10c94974d580df8/app/helpers/tab_helper.rb#L69) be sure to wrap `html_options` under the `html_options` keyword argument.
Be careful, as this behavior can be confused with the `ActionView` helper method [`link_to`](https://api.rubyonrails.org/v5.2.3/classes/ActionView/Helpers/UrlHelper.html#method-i-link_to) that does not require additional wrapping of `html_options`
-`nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { data: { track_label: "groups_dropdown", track_action: "click_dropdown" } })`
+```ruby
+# Bad
+= nav_link(controller: ['dashboard/groups', 'explore/groups'], data: { track_label: "explore_groups", track_action: "click_button" })
-vs
+# Good
+= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { data: { track_label: "explore_groups", track_action: "click_button" } })
-`link_to assigned_issues_dashboard_path, title: _('Issues'), data: { track_label: 'main_navigation', track_action: 'click_issues_link' }`
+# Good (other helpers)
+= link_to explore_groups_path, title: _("Explore"), data: { track_label: "explore_groups", track_action: "click_button" }
+```
### Tracking within Vue components
@@ -186,17 +244,19 @@ export default {
return {
expanded: false,
tracking: {
- label: 'left_sidebar'
- }
+ label: 'left_sidebar',
+ },
};
},
-}
+};
```
-The mixin provides a `track` method that can be called within the template, or from component methods. An example of the whole implementation might look like the following.
+The mixin provides a `track` method that can be called within the template,
+or from component methods. An example of the whole implementation might look like this:
```javascript
export default {
+ name: 'RightSidebar',
mixins: [Tracking.mixin({ label: 'right_sidebar' })],
data() {
return {
@@ -206,26 +266,84 @@ export default {
methods: {
toggle() {
this.expanded = !this.expanded;
- this.track('click_toggle', { value: this.expanded })
+ // Additional data will be merged, like `value` below
+ this.track('click_toggle', { value: Number(this.expanded) });
}
}
};
```
-And if needed within the template, you can use the `track` method directly as well.
+The event data can be provided with a `tracking` object, declared in the `data` function,
+or as a `computed property`.
+
+```javascript
+export default {
+ name: 'RightSidebar',
+ mixins: [Tracking.mixin()],
+ data() {
+ return {
+ tracking: {
+ label: 'right_sidebar',
+ // category: '',
+ // property: '',
+ // value: '',
+ },
+ };
+ },
+};
+```
+
+The event data can be provided directly in the `track` function as well.
+This object will merge with any previously provided options.
+
+```javascript
+this.track('click_button', {
+ label: 'right_sidebar',
+});
+```
+
+Lastly, if needed within the template, you can use the `track` method directly as well.
```html
```
+#### Testing example
+
+```javascript
+import { mockTracking } from 'helpers/tracking_helper';
+// mockTracking(category, documentOverride, spyMethod)
+
+describe('RightSidebar.vue', () => {
+ let trackingSpy;
+ let wrapper;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ const findToggle = () => wrapper.find('[data-testid="toggle"]');
+
+ it('tracks turning off toggle', () => {
+ findToggle().trigger('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
+ label: 'right_sidebar',
+ value: 0,
+ });
+ });
+});
+```
+
### Tracking in raw JavaScript
Custom event tracking and instrumentation can be added by directly calling the `Tracking.event` static function. The following example demonstrates tracking a click on a button by calling `Tracking.event` manually.
@@ -234,64 +352,35 @@ Custom event tracking and instrumentation can be added by directly calling the `
import Tracking from '~/tracking';
const button = document.getElementById('create_from_template_button');
+
button.addEventListener('click', () => {
Tracking.event('dashboard:projects:index', 'click_button', {
label: 'create_from_template',
property: 'template_preview',
- value: 'rails',
- });
-})
-```
-
-### Tests and test helpers
-
-In Jest particularly in Vue tests, you can use the following:
-
-```javascript
-import { mockTracking } from 'helpers/tracking_helper';
-
-describe('MyTracking', () => {
- let spy;
-
- beforeEach(() => {
- spy = mockTracking('_category_', wrapper.element, jest.spyOn);
- });
-
- it('tracks an event when clicked on feedback', () => {
- wrapper.find('.discover-feedback-icon').trigger('click');
-
- expect(spy).toHaveBeenCalledWith('_category_', 'click_button', {
- label: 'security-discover-feedback-cta',
- property: '0',
- });
});
});
```
-In obsolete Karma tests it's used as below:
+#### Testing example
```javascript
-import { mockTracking, triggerEvent } from 'spec/helpers/tracking_helper';
+import Tracking from '~/tracking';
-describe('my component', () => {
- let trackingSpy;
+describe('MyTracking', () => {
+ let wrapper;
beforeEach(() => {
- trackingSpy = mockTracking('_category_', vm.$el, spyOn);
+ jest.spyOn(Tracking, 'event');
});
- const triggerEvent = () => {
- // action which should trigger a event
- };
+ const findButton = () => wrapper.find('[data-testid="create_from_template"]');
- it('tracks an event when toggled', () => {
- expect(trackingSpy).not.toHaveBeenCalled();
+ it('tracks event', () => {
+ findButton().trigger('click');
- triggerEvent('a.toggle');
-
- expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
- label: 'right_sidebar',
- property: 'confidentiality',
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'create_from_template',
+ property: 'template_preview',
});
});
});
@@ -355,12 +444,6 @@ There are several tools for developing and testing Snowplow Event
**{check-circle}** Available, **{status_preparing}** In progress, **{dotted-circle}** Not Planned
-### Preparing your MR for Review
-
-1. For frontend events, in the MR description section, add a screenshot of the event's relevant section using the [Snowplow Analytics Debugger](https://chrome.google.com/webstore/detail/snowplow-analytics-debugg/jbnlcgeengmijcghameodeaenefieedm) Chrome browser extension.
-1. For backend events, please use Snowplow Micro and add the output of the Snowplow Micro good events `GET http://localhost:9090/micro/good`.
-1. Include a member of the Product Intelligence team as a reviewer of your MR. Mention `@gitlab-org/growth/product_intelligence/engineers` in your MR to request a review.
-
### Snowplow Analytics Debugger Chrome Extension
Snowplow Analytics Debugger is a browser extension for testing frontend events. This works on production, staging and local development environments.
diff --git a/doc/development/usage_ping/product_intelligence_review.md b/doc/development/usage_ping/product_intelligence_review.md
index c667bc8354c..e90e93a2c81 100644
--- a/doc/development/usage_ping/product_intelligence_review.md
+++ b/doc/development/usage_ping/product_intelligence_review.md
@@ -34,7 +34,7 @@ Product Intelligence files.
### Roles and process
-The merge request **author** should:
+#### The merge request **author** should
- Decide whether a Product Intelligence review is needed.
- If a Product Intelligence review is needed, add the labels
@@ -48,7 +48,15 @@ The merge request **author** should:
[Metrics Dictionary](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/development/usage_ping/dictionary.md) if it is needed.
- Add a changelog [according to guidelines](../changelog.md).
-The Product Intelligence **reviewer** should:
+##### When adding or modifiying Snowplow events
+
+- For frontend events, when relevant, add a screenshot of the event in
+ the [testing tool](../snowplow.md#developing-and-testing-snowplow) used.
+- For backend events, when relevant, add the output of the Snowplow Micro
+ good events `GET http://localhost:9090/micro/good` (it might be a good idea
+ to reset with `GET http://localhost:9090/micro/reset` first).
+
+#### The Product Intelligence **reviewer** should
- Perform a first-pass review on the merge request and suggest improvements to the author.
- Approve the MR, and relabel the MR with `~"product intelligence::approved"`.
@@ -71,6 +79,9 @@ Any of the Product Intelligence engineers can be assigned for the Product Intell
- For tracking using Redis HLL (HyperLogLog):
- Check the Redis slot.
- Check if a [feature flag is needed](index.md#recommendations).
+- For tracking with Snowplow:
+ - Check that the [event taxonomy](../snowplow.md#structured-event-taxonomy) is correct.
+ - Check the [usage recomendations](../snowplow.md#usage-recommendations).
- Metrics YAML definitions:
- Check the metric `description`.
- Check the metrics `key_path`.
diff --git a/doc/user/application_security/policies/index.md b/doc/user/application_security/policies/index.md
index 33d792001e3..208fbdfa5f3 100644
--- a/doc/user/application_security/policies/index.md
+++ b/doc/user/application_security/policies/index.md
@@ -55,29 +55,46 @@ Feature.disable(:security_orchestration_policies_configuration, Project.find( {
+ const mockNamespace = {
+ full_name: 'GitLab Org / GitLab',
+ path: '/gitlab-org/gitlab/-/edit',
+ };
+
+ const createPopoverMountEl = ({
+ lockedByApplicationSetting = false,
+ lockedByAncestor = false,
+ }) => {
+ const popoverMountEl = document.createElement('div');
+ popoverMountEl.classList.add('js-cascading-settings-lock-popover-target');
+
+ const popoverData = {
+ locked_by_application_setting: lockedByApplicationSetting,
+ locked_by_ancestor: lockedByAncestor,
+ };
+
+ if (lockedByApplicationSetting) {
+ popoverMountEl.setAttribute('data-popover-data', JSON.stringify(popoverData));
+ } else if (lockedByAncestor) {
+ popoverMountEl.setAttribute(
+ 'data-popover-data',
+ JSON.stringify({ ...popoverData, ancestor_namespace: mockNamespace }),
+ );
+ }
+
+ document.body.appendChild(popoverMountEl);
+
+ return popoverMountEl;
+ };
+
+ let wrapper;
+ const createWrapper = () => {
+ wrapper = mountExtended(LockPopovers);
+ };
+
+ const findPopover = () => extendedWrapper(wrapper.find(GlPopover));
+ const findByTextInPopover = (text, options) =>
+ findPopover().findByText((_, element) => element.textContent === text, options);
+
+ const expectPopoverMessageExists = (message) => {
+ expect(findByTextInPopover(message).exists()).toBe(true);
+ };
+ const expectCorrectPopoverTarget = (popoverMountEl, popover = findPopover()) => {
+ expect(popover.props('target')).toEqual(popoverMountEl);
+ };
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe('when setting is locked by an application setting', () => {
+ let popoverMountEl;
+
+ beforeEach(() => {
+ popoverMountEl = createPopoverMountEl({ lockedByApplicationSetting: true });
+ createWrapper();
+ });
+
+ it('displays correct popover message', () => {
+ expectPopoverMessageExists('This setting has been enforced by an instance admin.');
+ });
+
+ it('sets `target` prop correctly', () => {
+ expectCorrectPopoverTarget(popoverMountEl);
+ });
+ });
+
+ describe('when setting is locked by an ancestor namespace', () => {
+ let popoverMountEl;
+
+ beforeEach(() => {
+ popoverMountEl = createPopoverMountEl({ lockedByAncestor: true });
+ createWrapper();
+ });
+
+ it('displays correct popover message', () => {
+ expectPopoverMessageExists(
+ `This setting has been enforced by an owner of ${mockNamespace.full_name}.`,
+ );
+ });
+
+ it('displays link to ancestor namespace', () => {
+ expect(
+ findByTextInPopover(mockNamespace.full_name, {
+ selector: `a[href="${mockNamespace.path}"]`,
+ }).exists(),
+ ).toBe(true);
+ });
+
+ it('sets `target` prop correctly', () => {
+ expectCorrectPopoverTarget(popoverMountEl);
+ });
+ });
+
+ describe('when setting is locked by an application setting and an ancestor namespace', () => {
+ let popoverMountEl;
+
+ beforeEach(() => {
+ popoverMountEl = createPopoverMountEl({
+ lockedByAncestor: true,
+ lockedByApplicationSetting: true,
+ });
+ createWrapper();
+ });
+
+ it('application setting takes precedence and correct message is shown', () => {
+ expectPopoverMessageExists('This setting has been enforced by an instance admin.');
+ });
+
+ it('sets `target` prop correctly', () => {
+ expectCorrectPopoverTarget(popoverMountEl);
+ });
+ });
+
+ describe('when setting is not locked', () => {
+ beforeEach(() => {
+ createPopoverMountEl({
+ lockedByAncestor: false,
+ lockedByApplicationSetting: false,
+ });
+ createWrapper();
+ });
+
+ it('does not render popover', () => {
+ expect(findPopover().exists()).toBe(false);
+ });
+ });
+
+ describe('when there are multiple mount elements', () => {
+ let popoverMountEl1;
+ let popoverMountEl2;
+
+ beforeEach(() => {
+ popoverMountEl1 = createPopoverMountEl({ lockedByApplicationSetting: true });
+ popoverMountEl2 = createPopoverMountEl({ lockedByAncestor: true });
+ createWrapper();
+ });
+
+ it('mounts multiple popovers', () => {
+ const popovers = wrapper.findAll(GlPopover).wrappers;
+
+ expectCorrectPopoverTarget(popoverMountEl1, popovers[0]);
+ expectCorrectPopoverTarget(popoverMountEl2, popovers[1]);
+ });
+ });
+});
diff --git a/spec/frontend/fixtures/static/whats_new_notification.html b/spec/frontend/fixtures/static/whats_new_notification.html
index 30d5eea91cc..3b4dbdf7d36 100644
--- a/spec/frontend/fixtures/static/whats_new_notification.html
+++ b/spec/frontend/fixtures/static/whats_new_notification.html
@@ -1,5 +1,5 @@
-
+
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 2a226ca8ade..6180cd8e94d 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -178,6 +178,30 @@ describe('timeIntervalInWords', () => {
});
});
+describe('humanizeTimeInterval', () => {
+ it.each`
+ intervalInSeconds | expected
+ ${0} | ${'0 seconds'}
+ ${1} | ${'1 second'}
+ ${1.48} | ${'1.5 seconds'}
+ ${2} | ${'2 seconds'}
+ ${60} | ${'1 minute'}
+ ${91} | ${'1.5 minutes'}
+ ${120} | ${'2 minutes'}
+ ${3600} | ${'1 hour'}
+ ${5401} | ${'1.5 hours'}
+ ${7200} | ${'2 hours'}
+ ${86400} | ${'1 day'}
+ ${129601} | ${'1.5 days'}
+ ${172800} | ${'2 days'}
+ `(
+ 'returns "$expected" when the time interval is $intervalInSeconds seconds',
+ ({ intervalInSeconds, expected }) => {
+ expect(datetimeUtility.humanizeTimeInterval(intervalInSeconds)).toBe(expected);
+ },
+ );
+});
+
describe('dateInWords', () => {
const date = new Date('07/01/2016');
diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js
index 8a2c7ac246f..e8fb036368a 100644
--- a/spec/frontend/pipelines/graph/graph_component_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_spec.js
@@ -1,10 +1,11 @@
import { mount, shallowMount } from '@vue/test-utils';
-import { GRAPHQL, STAGE_VIEW } from '~/pipelines/components/graph/constants';
+import { GRAPHQL, LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import JobItem from '~/pipelines/components/graph/job_item.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
+import { listByLayers } from '~/pipelines/components/parsing_utils';
import {
generateResponse,
mockPipelineResponse,
@@ -17,6 +18,7 @@ describe('graph component', () => {
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findLinksLayer = () => wrapper.find(LinksLayer);
const findStageColumns = () => wrapper.findAll(StageColumnComponent);
+ const findStageNameInJob = () => wrapper.find('[data-testid="stage-name-in-job"]');
const defaultProps = {
pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
@@ -82,6 +84,10 @@ describe('graph component', () => {
expect(findLinksLayer().exists()).toBe(true);
});
+ it('does not display stage name on the job in default (stage) mode', () => {
+ expect(findStageNameInJob().exists()).toBe(false);
+ });
+
describe('when column requests a refresh', () => {
beforeEach(() => {
findStageColumns().at(0).vm.$emit('refreshPipelineGraph');
@@ -93,7 +99,7 @@ describe('graph component', () => {
});
describe('when links are present', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({
mountFn: mount,
stubOverride: { 'job-item': false },
@@ -132,4 +138,24 @@ describe('graph component', () => {
expect(findLinkedColumns()).toHaveLength(2);
});
});
+
+ describe('in layers mode', () => {
+ beforeEach(() => {
+ createComponent({
+ mountFn: mount,
+ stubOverride: {
+ 'job-item': false,
+ 'job-group-dropdown': false,
+ },
+ props: {
+ viewType: LAYER_VIEW,
+ pipelineLayers: listByLayers(defaultProps.pipeline),
+ },
+ });
+ });
+
+ it('displays the stage name on the job', () => {
+ expect(findStageNameInJob().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js
index b323e1d8a06..5d8e70bac31 100644
--- a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js
+++ b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, mount } from '@vue/test-utils';
import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue';
describe('job group dropdown component', () => {
@@ -65,12 +65,16 @@ describe('job group dropdown component', () => {
let wrapper;
const findButton = () => wrapper.find('button');
+ const createComponent = ({ mountFn = shallowMount }) => {
+ wrapper = mountFn(JobGroupDropdown, { propsData: { group } });
+ };
+
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
- wrapper = shallowMount(JobGroupDropdown, { propsData: { group } });
+ createComponent({ mountFn: mount });
});
it('renders button with group name and size', () => {
diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js
index cb2837cbb39..4c7ea5edda9 100644
--- a/spec/frontend/pipelines/graph/job_item_spec.js
+++ b/spec/frontend/pipelines/graph/job_item_spec.js
@@ -122,7 +122,7 @@ describe('pipeline graph job item', () => {
},
});
- expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test');
+ expect(findJobWithoutLink().attributes('title')).toBe('test');
});
it('should not render status label when it is provided', () => {
@@ -138,7 +138,7 @@ describe('pipeline graph job item', () => {
},
});
- expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test - success');
+ expect(findJobWithoutLink().attributes('title')).toBe('test - success');
});
});
diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js
index 0d0a99eede8..f9f6c96a1a6 100644
--- a/spec/frontend/pipelines/graph/stage_column_component_spec.js
+++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js
@@ -28,7 +28,7 @@ const mockGroups = Array(4)
});
const defaultProps = {
- title: 'Fish',
+ name: 'Fish',
groups: mockGroups,
pipelineId: 159,
};
@@ -62,7 +62,7 @@ describe('stage column component', () => {
});
it('should render provided title', () => {
- expect(findStageColumnTitle().text()).toBe(defaultProps.title);
+ expect(findStageColumnTitle().text()).toBe(defaultProps.name);
});
it('should render the provided groups', () => {
@@ -119,7 +119,7 @@ describe('stage column component', () => {
],
},
],
- title: 'test

',
+ name: 'test

',
},
});
});
diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js
index e8aace14db4..2aab50985cf 100644
--- a/spec/frontend/projects/pipelines/charts/components/app_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js
@@ -10,6 +10,7 @@ import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_char
jest.mock('~/lib/utils/url_utility');
const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} };
+const LeadTimeChartsStub = { name: 'LeadTimeCharts', render: () => {} };
describe('ProjectsPipelinesChartsApp', () => {
let wrapper;
@@ -25,6 +26,7 @@ describe('ProjectsPipelinesChartsApp', () => {
},
stubs: {
DeploymentFrequencyCharts: DeploymentFrequencyChartsStub,
+ LeadTimeCharts: LeadTimeChartsStub,
},
},
mountOptions,
@@ -44,6 +46,7 @@ describe('ProjectsPipelinesChartsApp', () => {
const findGlTabs = () => wrapper.find(GlTabs);
const findAllGlTab = () => wrapper.findAll(GlTab);
const findGlTabAt = (i) => findAllGlTab().at(i);
+ const findLeadTimeCharts = () => wrapper.find(LeadTimeChartsStub);
const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub);
const findPipelineCharts = () => wrapper.find(PipelineCharts);
@@ -51,15 +54,23 @@ describe('ProjectsPipelinesChartsApp', () => {
expect(findPipelineCharts().exists()).toBe(true);
});
+ it('renders the lead time charts', () => {
+ expect(findLeadTimeCharts().exists()).toBe(true);
+ });
+
describe('when shouldRenderDeploymentFrequencyCharts is true', () => {
beforeEach(() => {
createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: true } });
});
- it('renders the deployment frequency charts in a tab', () => {
+ it('renders the expected tabs', () => {
expect(findGlTabs().exists()).toBe(true);
expect(findGlTabAt(0).attributes('title')).toBe('Pipelines');
expect(findGlTabAt(1).attributes('title')).toBe('Deployments');
+ expect(findGlTabAt(2).attributes('title')).toBe('Lead Time');
+ });
+
+ it('renders the deployment frequency charts', () => {
expect(findDeploymentFrequencyCharts().exists()).toBe(true);
});
@@ -108,6 +119,7 @@ describe('ProjectsPipelinesChartsApp', () => {
describe('when provided with a query param', () => {
it.each`
chart | tab
+ ${'lead-time'} | ${'2'}
${'deployments'} | ${'1'}
${'pipelines'} | ${'0'}
${'fake'} | ${'0'}
@@ -160,8 +172,13 @@ describe('ProjectsPipelinesChartsApp', () => {
createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: false } });
});
+ it('renders the expected tabs', () => {
+ expect(findGlTabs().exists()).toBe(true);
+ expect(findGlTabAt(0).attributes('title')).toBe('Pipelines');
+ expect(findGlTabAt(1).attributes('title')).toBe('Lead Time');
+ });
+
it('does not render the deployment frequency charts in a tab', () => {
- expect(findGlTabs().exists()).toBe(false);
expect(findDeploymentFrequencyCharts().exists()).toBe(false);
});
});
diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js
index ad062d04140..45c4682208b 100644
--- a/spec/frontend/whats_new/components/app_spec.js
+++ b/spec/frontend/whats_new/components/app_spec.js
@@ -1,4 +1,4 @@
-import { GlDrawer, GlInfiniteScroll, GlTabs } from '@gitlab/ui';
+import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
@@ -21,12 +21,9 @@ describe('App', () => {
let actions;
let state;
let trackingSpy;
- let gitlabDotCom = true;
const buildProps = () => ({
- storageKey: 'storage-key',
- versions: ['3.11', '3.10'],
- gitlabDotCom,
+ versionDigest: 'version-digest',
});
const buildWrapper = () => {
@@ -91,7 +88,7 @@ describe('App', () => {
});
it('dispatches openDrawer and tracking calls when mounted', () => {
- expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key');
+ expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'version-digest');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
label: 'namespace_id',
value: 'namespace-840',
@@ -176,54 +173,4 @@ describe('App', () => {
);
});
});
-
- describe('self managed', () => {
- const findTabs = () => wrapper.find(GlTabs);
-
- const clickSecondTab = async () => {
- const secondTab = wrapper.findAll('.nav-link').at(1);
- await secondTab.trigger('click');
- await new Promise((resolve) => requestAnimationFrame(resolve));
- };
-
- beforeEach(() => {
- gitlabDotCom = false;
- setup();
- });
-
- it('renders tabs with drawer body height and content', () => {
- const scroll = findInfiniteScroll();
- const tabs = findTabs();
-
- expect(scroll.exists()).toBe(false);
- expect(tabs.attributes().style).toBe(`height: ${MOCK_DRAWER_BODY_HEIGHT}px;`);
- expect(wrapper.find('h5').text()).toBe('Whats New Drawer');
- });
-
- describe('fetchVersion', () => {
- beforeEach(() => {
- actions.fetchItems.mockClear();
- });
-
- it('when version isnt fetched, clicking a tab calls fetchItems', async () => {
- const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion');
- await clickSecondTab();
-
- expect(fetchVersionSpy).toHaveBeenCalledWith('3.10');
- expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { version: '3.10' });
- });
-
- it('when version has been fetched, clicking a tab calls fetchItems', async () => {
- wrapper.vm.$store.state.features.push({ title: 'GitLab Stories', release: 3.1 });
- await wrapper.vm.$nextTick();
-
- const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion');
- await clickSecondTab();
-
- expect(fetchVersionSpy).toHaveBeenCalledWith('3.10');
- expect(actions.fetchItems).not.toHaveBeenCalled();
- expect(wrapper.find('.tab-pane.active h5').text()).toBe('GitLab Stories');
- });
- });
- });
});
diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js
index c4125d28aba..39ad526cf14 100644
--- a/spec/frontend/whats_new/store/actions_spec.js
+++ b/spec/frontend/whats_new/store/actions_spec.js
@@ -11,9 +11,12 @@ describe('whats new actions', () => {
useLocalStorageSpy();
it('should commit openDrawer', () => {
- testAction(actions.openDrawer, 'storage-key', {}, [{ type: types.OPEN_DRAWER }]);
+ testAction(actions.openDrawer, 'digest-hash', {}, [{ type: types.OPEN_DRAWER }]);
- expect(window.localStorage.setItem).toHaveBeenCalledWith('storage-key', 'false');
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(
+ 'display-whats-new-notification',
+ 'digest-hash',
+ );
});
});
@@ -45,12 +48,12 @@ describe('whats new actions', () => {
axiosMock.reset();
axiosMock
- .onGet('/-/whats_new', { params: { page: 8, version: 40 } })
+ .onGet('/-/whats_new', { params: { page: 8 } })
.replyOnce(200, [{ title: 'GitLab Stories' }]);
testAction(
actions.fetchItems,
- { page: 8, version: 40 },
+ { page: 8 },
{},
expect.arrayContaining([
{ type: types.ADD_FEATURES, payload: [{ title: 'GitLab Stories' }] },
diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js
index e3e390f4394..e1de65df30f 100644
--- a/spec/frontend/whats_new/utils/notification_spec.js
+++ b/spec/frontend/whats_new/utils/notification_spec.js
@@ -1,5 +1,5 @@
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
-import { setNotification, getStorageKey } from '~/whats_new/utils/notification';
+import { setNotification, getVersionDigest } from '~/whats_new/utils/notification';
describe('~/whats_new/utils/notification', () => {
useLocalStorageSpy();
@@ -33,10 +33,23 @@ describe('~/whats_new/utils/notification', () => {
expect(notificationEl.classList).toContain('with-notifications');
});
- it('removes class and count element when storage key is true', () => {
+ it('removes class and count element when legacy storage key is false', () => {
const notificationEl = findNotificationEl();
notificationEl.classList.add('with-notifications');
- localStorage.setItem('storage-key', 'false');
+ localStorage.setItem('display-whats-new-notification-13.10', 'false');
+
+ expect(findNotificationCountEl()).toExist();
+
+ subject();
+
+ expect(findNotificationCountEl()).not.toExist();
+ expect(notificationEl.classList).not.toContain('with-notifications');
+ });
+
+ it('removes class and count element when storage key has current digest', () => {
+ const notificationEl = findNotificationEl();
+ notificationEl.classList.add('with-notifications');
+ localStorage.setItem('display-whats-new-notification', 'version-digest');
expect(findNotificationCountEl()).toExist();
@@ -47,9 +60,9 @@ describe('~/whats_new/utils/notification', () => {
});
});
- describe('getStorageKey', () => {
+ describe('getVersionDigest', () => {
it('retrieves the storage key data attribute from the el', () => {
- expect(getStorageKey(getAppEl())).toBe('storage-key');
+ expect(getVersionDigest(getAppEl())).toBe('version-digest');
});
});
});
diff --git a/spec/helpers/commits_helper_spec.rb b/spec/helpers/commits_helper_spec.rb
index 9e66fee10c5..86ed133e599 100644
--- a/spec/helpers/commits_helper_spec.rb
+++ b/spec/helpers/commits_helper_spec.rb
@@ -200,7 +200,7 @@ RSpec.describe CommitsHelper do
end
it 'returns data for cherry picking into a project' do
- expect(helper.cherry_pick_projects_data(project)).to match_array([
+ expect(helper.cherry_pick_projects_data(forked_project)).to match_array([
{ id: project.id.to_s, name: project.full_path, refsUrl: refs_project_path(project) },
{ id: forked_project.id.to_s, name: forked_project.full_path, refsUrl: refs_project_path(forked_project) }
])
diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb
index b436f4ab0c9..8c08b06d8a8 100644
--- a/spec/helpers/namespaces_helper_spec.rb
+++ b/spec/helpers/namespaces_helper_spec.rb
@@ -194,4 +194,75 @@ RSpec.describe NamespacesHelper do
end
end
end
+
+ describe '#cascading_namespace_settings_enabled?' do
+ subject { helper.cascading_namespace_settings_enabled? }
+
+ context 'when `cascading_namespace_settings` feature flag is enabled' do
+ it 'returns `true`' do
+ expect(subject).to be(true)
+ end
+ end
+
+ context 'when `cascading_namespace_settings` feature flag is disabled' do
+ before do
+ stub_feature_flags(cascading_namespace_settings: false)
+ end
+
+ it 'returns `false`' do
+ expect(subject).to be(false)
+ end
+ end
+ end
+
+ describe '#cascading_namespace_settings_popover_data' do
+ attribute = :delayed_project_removal
+
+ subject do
+ helper.cascading_namespace_settings_popover_data(
+ attribute,
+ subgroup1,
+ -> (locked_ancestor) { edit_group_path(locked_ancestor, anchor: 'js-permissions-settings') }
+ )
+ end
+
+ context 'when locked by an application setting' do
+ before do
+ allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_by_application_setting?").and_return(true)
+ allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_by_ancestor?").and_return(false)
+ end
+
+ it 'returns expected hash' do
+ expect(subject).to match({
+ popover_data: {
+ locked_by_application_setting: true,
+ locked_by_ancestor: false
+ }.to_json,
+ testid: 'cascading-settings-lock-icon'
+ })
+ end
+ end
+
+ context 'when locked by an ancestor namespace' do
+ before do
+ allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_by_application_setting?").and_return(false)
+ allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_by_ancestor?").and_return(true)
+ allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_ancestor").and_return(admin_group.namespace_settings)
+ end
+
+ it 'returns expected hash' do
+ expect(subject).to match({
+ popover_data: {
+ locked_by_application_setting: false,
+ locked_by_ancestor: true,
+ ancestor_namespace: {
+ full_name: admin_group.full_name,
+ path: edit_group_path(admin_group, anchor: 'js-permissions-settings')
+ }
+ }.to_json,
+ testid: 'cascading-settings-lock-icon'
+ })
+ end
+ end
+ end
end
diff --git a/spec/helpers/whats_new_helper_spec.rb b/spec/helpers/whats_new_helper_spec.rb
index 017826921ff..f7f0d19db30 100644
--- a/spec/helpers/whats_new_helper_spec.rb
+++ b/spec/helpers/whats_new_helper_spec.rb
@@ -3,25 +3,13 @@
require 'spec_helper'
RSpec.describe WhatsNewHelper do
- describe '#whats_new_storage_key' do
- subject { helper.whats_new_storage_key }
+ describe '#whats_new_version_digest' do
+ let(:digest) { 'digest' }
- context 'when version exist' do
- let(:release_item) { double(:item) }
+ it 'calls ReleaseHighlight.most_recent_version_digest' do
+ expect(ReleaseHighlight).to receive(:most_recent_version_digest).and_return(digest)
- before do
- allow(ReleaseHighlight).to receive(:versions).and_return([84.0])
- end
-
- it { is_expected.to eq('display-whats-new-notification-84.0') }
- end
-
- context 'when most recent release highlights do NOT exist' do
- before do
- allow(ReleaseHighlight).to receive(:versions).and_return(nil)
- end
-
- it { is_expected.to be_nil }
+ expect(helper.whats_new_version_digest).to eq(digest)
end
end
@@ -44,14 +32,4 @@ RSpec.describe WhatsNewHelper do
end
end
end
-
- describe '#whats_new_versions' do
- let(:versions) { [84.0] }
-
- it 'returns ReleaseHighlight.versions' do
- expect(ReleaseHighlight).to receive(:versions).and_return(versions)
-
- expect(helper.whats_new_versions).to eq(versions)
- end
- end
end
diff --git a/spec/models/release_highlight_spec.rb b/spec/models/release_highlight_spec.rb
index 60087278671..673451b5e76 100644
--- a/spec/models/release_highlight_spec.rb
+++ b/spec/models/release_highlight_spec.rb
@@ -13,26 +13,6 @@ RSpec.describe ReleaseHighlight, :clean_gitlab_redis_cache do
ReleaseHighlight.instance_variable_set(:@file_paths, nil)
end
- describe '.for_version' do
- subject { ReleaseHighlight.for_version(version: version) }
-
- let(:version) { '1.1' }
-
- context 'with version param that exists' do
- it 'returns items from that version' do
- expect(subject.items.first['title']).to eq("It's gonna be a bright")
- end
- end
-
- context 'with version param that does NOT exist' do
- let(:version) { '84.0' }
-
- it 'returns nil' do
- expect(subject).to be_nil
- end
- end
- end
-
describe '.paginated' do
let(:dot_com) { false }
@@ -143,28 +123,27 @@ RSpec.describe ReleaseHighlight, :clean_gitlab_redis_cache do
end
end
- describe '.versions' do
- subject { described_class.versions }
+ describe '.most_recent_version_digest' do
+ subject { ReleaseHighlight.most_recent_version_digest }
it 'uses process memory cache' do
- expect(Gitlab::ProcessMemoryCache.cache_backend).to receive(:fetch).with("release_highlight:versions:#{Gitlab.revision}", { expires_in: described_class::CACHE_DURATION })
+ expect(Gitlab::ProcessMemoryCache.cache_backend).to receive(:fetch).with("release_highlight:most_recent_version_digest:#{Gitlab.revision}", expires_in: described_class::CACHE_DURATION)
subject
end
- it 'returns versions from the file paths' do
- expect(subject).to eq(['1.5', '1.2', '1.1'])
+ context 'when recent release items exist' do
+ it 'returns a digest from the release of the first item of the most recent file' do
+ # this value is coming from fixture data
+ expect(subject).to eq(Digest::SHA256.hexdigest('01.05'))
+ end
end
- context 'when there are more than 12 versions' do
- let(:file_paths) do
- i = 0
- Array.new(20) { "20201225_01_#{i += 1}.yml" }
- end
+ context 'when recent release items do NOT exist' do
+ it 'returns nil' do
+ allow(ReleaseHighlight).to receive(:paginated).and_return(nil)
- it 'limits to 12 versions' do
- allow(ReleaseHighlight).to receive(:file_paths).and_return(file_paths)
- expect(subject.count).to eq(12)
+ expect(subject).to be_nil
end
end
end
diff --git a/spec/requests/whats_new_controller_spec.rb b/spec/requests/whats_new_controller_spec.rb
index ba7b5d4c000..ffb31bdf9bb 100644
--- a/spec/requests/whats_new_controller_spec.rb
+++ b/spec/requests/whats_new_controller_spec.rb
@@ -35,16 +35,5 @@ RSpec.describe WhatsNewController, :clean_gitlab_redis_cache do
expect(response).to have_gitlab_http_status(:not_found)
end
end
-
- context 'with version param' do
- it 'returns items without pagination headers' do
- allow(ReleaseHighlight).to receive(:for_version).with(version: '42').and_return(highlights)
-
- get whats_new_path(version: 42), xhr: true
-
- expect(response.body).to eq(highlights.items.to_json)
- expect(response.headers['X-Next-Page']).to be_nil
- end
- end
end
end
diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb
index 14041ad0ac6..9e62eef14de 100644
--- a/spec/support/helpers/cycle_analytics_helpers.rb
+++ b/spec/support/helpers/cycle_analytics_helpers.rb
@@ -3,15 +3,15 @@
module CycleAnalyticsHelpers
include GitHelpers
- def wait_for_stages_to_load
- expect(page).to have_selector '.js-stage-table'
+ def wait_for_stages_to_load(selector = '.js-path-navigation')
+ expect(page).to have_selector selector
wait_for_requests
end
- def select_group(target_group)
+ def select_group(target_group, ready_selector = '.js-path-navigation')
visit group_analytics_cycle_analytics_path(target_group)
- wait_for_stages_to_load
+ wait_for_stages_to_load(ready_selector)
end
def toggle_dropdown(field)
diff --git a/spec/support/shared_examples/features/cascading_settings_shared_examples.rb b/spec/support/shared_examples/features/cascading_settings_shared_examples.rb
new file mode 100644
index 00000000000..29ef3da9a85
--- /dev/null
+++ b/spec/support/shared_examples/features/cascading_settings_shared_examples.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'a cascading setting' do
+ context 'when setting is enforced by an ancestor group' do
+ before do
+ visit group_path
+
+ page.within form_group_selector do
+ find(setting_field_selector).check
+ find('[data-testid="enforce-for-all-subgroups-checkbox"]').check
+ end
+
+ click_save_button
+ end
+
+ it 'disables setting in subgroups' do
+ visit subgroup_path
+
+ expect(find("#{setting_field_selector}[disabled]")).to be_checked
+ end
+
+ it 'does not show enforcement checkbox in subgroups' do
+ visit subgroup_path
+
+ expect(page).not_to have_selector '[data-testid="enforce-for-all-subgroups-checkbox"]'
+ end
+
+ it 'displays lock icon with popover', :js do
+ visit subgroup_path
+
+ page.within form_group_selector do
+ find('[data-testid="cascading-settings-lock-icon"]').click
+ end
+
+ page.within '[data-testid="cascading-settings-lock-popover"]' do
+ expect(page).to have_text 'This setting has been enforced by an owner of Foo bar.'
+ expect(page).to have_link 'Foo bar', href: setting_path
+ end
+ end
+ end
+end