diff options
33 files changed, 502 insertions, 85 deletions
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index e6b515e16bf..183acceadf9 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -106,6 +106,9 @@ .if-dot-com-gitlab-org-and-security-merge-request: &if-dot-com-gitlab-org-and-security-merge-request if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/security$)/ && $CI_MERGE_REQUEST_IID' +.if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-qa: &if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-qa + if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/security$)/ && $CI_MERGE_REQUEST_IID && $QA_MANUAL_FF_PACKAGE_AND_QA' + .if-dot-com-gitlab-org-and-security-tag: &if-dot-com-gitlab-org-and-security-tag if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/security$)/ && $CI_COMMIT_TAG' @@ -769,10 +772,13 @@ rules: - <<: *if-not-ee when: never - - <<: *if-dot-com-gitlab-org-and-security-merge-request + - <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-qa changes: *feature-flag-development-config-patterns when: manual allow_failure: true + - <<: *if-dot-com-gitlab-org-and-security-merge-request + changes: *feature-flag-development-config-patterns + allow_failure: true ############### # Rails rules # diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 08cf0197993..08942374120 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -2,6 +2,8 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import Cookies from 'js-cookie'; import { debounce } from 'lodash'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { parseBoolean } from '~/lib/utils/common_utils'; export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; @@ -112,5 +114,8 @@ export default class ContextualSidebar { const collapse = parseBoolean(Cookies.get('sidebar_collapsed')); this.toggleCollapsedSidebar(collapse, true); } + + initInviteMembersModal(); + initInviteMembersTrigger(); } } diff --git a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql index 3448aa72964..c5f99a1657e 100644 --- a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql @@ -4,7 +4,7 @@ query groupUsersSearch($search: String!, $fullPath: ID!) { workspace: group(fullPath: $fullPath) { id - users: groupMembers(search: $search, relations: [DIRECT, INHERITED]) { + users: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { nodes { user { ...User diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index 05be427742c..bf3250f63a5 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -1,11 +1,12 @@ <script> -import { GlButton, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; +import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '../constants'; export default { - components: { GlButton, GlLink }, + components: { GlButton, GlLink, GlIcon }, props: { displayText: { type: String, @@ -53,13 +54,11 @@ export default { }, }, computed: { - isButton() { - return this.triggerElement === 'button'; - }, componentAttributes() { const baseAttributes = { class: this.classes, 'data-qa-selector': 'invite_members_button', + 'data-test-id': 'invite-members-button', }; if (this.event && this.label) { @@ -77,6 +76,9 @@ export default { this.trackExperimentOnShow(); }, methods: { + checkTrigger(targetTriggerElement) { + return this.triggerElement === targetTriggerElement; + }, openModal() { eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource }); }, @@ -87,12 +89,14 @@ export default { } }, }, + TRIGGER_ELEMENT_BUTTON, + TRIGGER_ELEMENT_SIDE_NAV, }; </script> <template> <gl-button - v-if="isButton" + v-if="checkTrigger($options.TRIGGER_ELEMENT_BUTTON)" v-bind="componentAttributes" :variant="variant" :icon="icon" @@ -100,6 +104,17 @@ export default { > {{ displayText }} </gl-button> + <gl-link + v-else-if="checkTrigger($options.TRIGGER_ELEMENT_SIDE_NAV)" + v-bind="componentAttributes" + data-is-link="true" + @click="openModal" + > + <span class="nav-icon-container"> + <gl-icon :name="icon" /> + </span> + <span class="nav-item-name"> {{ displayText }} </span> + </gl-link> <gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal"> {{ displayText }} </gl-link> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 02f38bfd3c5..c1a1107ebe3 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -25,3 +25,5 @@ export const API_MESSAGES = { }; export const USERS_FILTER_ALL = 'all'; export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id'; +export const TRIGGER_ELEMENT_BUTTON = 'button'; +export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav'; diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 2aa5c571157..fc657a064dd 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -5,7 +5,15 @@ import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(GlToast); +let initedInviteMembersModal; + export default function initInviteMembersModal() { + if (initedInviteMembersModal) { + // if we already loaded this in another part of the dom, we don't want to do it again + // else we will stack the modals + return false; + } + // https://gitlab.com/gitlab-org/gitlab/-/issues/344955 // bug lying in wait here for someone to put group and project invite in same screen // once that happens we'll need to mount these differently, perhaps split @@ -16,6 +24,8 @@ export default function initInviteMembersModal() { return false; } + initedInviteMembersModal = true; + return new Vue({ el, provide: { diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 0f7b6388441..bcd8bdd6638 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -95,8 +95,7 @@ class ApplicationRecord < ActiveRecord::Base end def self.declarative_enum(enum_mod) - values = enum_mod.definition.transform_values { |v| v[:value] } - enum(enum_mod.key => values) + enum(enum_mod.key => enum_mod.values) end def self.cached_column_list diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 9565d1a6a69..353a896b3fe 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -16,7 +16,6 @@ class Namespace < ApplicationRecord include Namespaces::Traversal::Linear include EachBatch - ignore_column :delayed_project_removal, remove_with: '14.1', remove_after: '2021-05-22' # Temporary column used for back-filling project namespaces. # Remove it once the back-filling of all project namespaces is done. ignore_column :tmp_project_id, remove_with: '14.7', remove_after: '2022-01-22' diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml new file mode 100644 index 00000000000..bccfa9897da --- /dev/null +++ b/app/views/groups/_invite_members_side_nav_link.html.haml @@ -0,0 +1,8 @@ +.js-invite-members-trigger{ data: { trigger_source: 'group-side-nav', + classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!', + icon: 'users', + display_text: title, + trigger_element: 'side-nav'} } + += render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu } += render 'groups/invite_members_modal', group: group diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 2e74d983397..ed3f2b0c6db 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -32,8 +32,6 @@ = render_if_exists 'groups/group_activity_analytics', group: @group -= render 'groups/invite_members_modal', group: @group - .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } .top-area.group-nav-container.justify-content-between .scrolling-tabs-container.inner-page-scroll-tabs diff --git a/app/views/projects/_invite_members_side_nav_link.html.haml b/app/views/projects/_invite_members_side_nav_link.html.haml new file mode 100644 index 00000000000..ea6174d19f0 --- /dev/null +++ b/app/views/projects/_invite_members_side_nav_link.html.haml @@ -0,0 +1,8 @@ +.js-invite-members-trigger{ data: { trigger_source: 'project-side-nav', + classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!', + icon: 'users', + display_text: title, + trigger_element: 'side-nav'} } + += render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu } += render 'projects/invite_members_modal', project: project diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml index 903d2d077ba..3f71368aff3 100644 --- a/app/views/shared/nav/_sidebar_menu.html.haml +++ b/app/views/shared/nav/_sidebar_menu.html.haml @@ -16,15 +16,4 @@ %span.badge.badge-pill.count{ **sidebar_menu.pill_html_options } = number_with_delimiter(sidebar_menu.pill_count) - %ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) } - = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do - %span.fly-out-top-item-container - %strong.fly-out-top-item-name - = sidebar_menu.title - - if sidebar_menu.has_pill? - %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options } - = number_with_delimiter(sidebar_menu.pill_count) - - - if sidebar_menu.has_renderable_items? - %li.divider.fly-out-top-item - = render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items + = render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu } diff --git a/app/views/shared/nav/_sidebar_submenu.html.haml b/app/views/shared/nav/_sidebar_submenu.html.haml new file mode 100644 index 00000000000..750e6c9ee57 --- /dev/null +++ b/app/views/shared/nav/_sidebar_submenu.html.haml @@ -0,0 +1,12 @@ +%ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) } + = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do + %span.fly-out-top-item-container + %strong.fly-out-top-item-name + = sidebar_menu.title + - if sidebar_menu.has_pill? + %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options } + = number_with_delimiter(sidebar_menu.pill_count) + + - if sidebar_menu.has_renderable_items? + %li.divider.fly-out-top-item + = render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items diff --git a/config/feature_flags/experiment/invite_members_in_side_nav.yml b/config/feature_flags/experiment/invite_members_in_side_nav.yml new file mode 100644 index 00000000000..7968a885374 --- /dev/null +++ b/config/feature_flags/experiment/invite_members_in_side_nav.yml @@ -0,0 +1,8 @@ +--- +name: invite_members_in_side_nav +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70451 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/342951 +milestone: '14.5' +type: experiment +group: group::expansion +default_enabled: false diff --git a/db/post_migrate/20210923192648_remove_foreign_keys_from_open_project_data_table.rb b/db/post_migrate/20210923192648_remove_foreign_keys_from_open_project_data_table.rb new file mode 100644 index 00000000000..1da5aad0fab --- /dev/null +++ b/db/post_migrate/20210923192648_remove_foreign_keys_from_open_project_data_table.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class RemoveForeignKeysFromOpenProjectDataTable < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + def up + with_lock_retries do + remove_foreign_key :open_project_tracker_data, column: :service_id + end + end + + def down + with_lock_retries do + add_foreign_key :open_project_tracker_data, :integrations, column: :service_id, on_delete: :cascade + end + end +end diff --git a/db/post_migrate/20210923192649_remove_open_project_data_table.rb b/db/post_migrate/20210923192649_remove_open_project_data_table.rb new file mode 100644 index 00000000000..252d7e07261 --- /dev/null +++ b/db/post_migrate/20210923192649_remove_open_project_data_table.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class RemoveOpenProjectDataTable < Gitlab::Database::Migration[1.0] + def up + drop_table :open_project_tracker_data + end + + def down + create_table :open_project_tracker_data do |t| + t.integer :service_id, index: { name: 'index_open_project_tracker_data_on_service_id' }, null: false + t.timestamps_with_timezone + t.string :encrypted_url, limit: 255 + t.string :encrypted_url_iv, limit: 255 + t.string :encrypted_api_url, limit: 255 + t.string :encrypted_api_url_iv, limit: 255 + t.string :encrypted_token, limit: 255 + t.string :encrypted_token_iv, limit: 255 + t.string :closed_status_id, limit: 5 + t.string :project_identifier_code, limit: 100 + end + end +end diff --git a/db/schema_migrations/20210923192648 b/db/schema_migrations/20210923192648 new file mode 100644 index 00000000000..5514ec65903 --- /dev/null +++ b/db/schema_migrations/20210923192648 @@ -0,0 +1 @@ +d9cb520f198893019b24c970ba409471e3d98581eb62f746320fc6e81a16af08
\ No newline at end of file diff --git a/db/schema_migrations/20210923192649 b/db/schema_migrations/20210923192649 new file mode 100644 index 00000000000..e91955f09fe --- /dev/null +++ b/db/schema_migrations/20210923192649 @@ -0,0 +1 @@ +12fb550e936ede5a8e83ab06f2fc535201e7a276295a2103564412ded32958f8
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index c5d619cb2a3..e84a69c1967 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -16583,30 +16583,6 @@ CREATE SEQUENCE onboarding_progresses_id_seq ALTER SEQUENCE onboarding_progresses_id_seq OWNED BY onboarding_progresses.id; -CREATE TABLE open_project_tracker_data ( - id bigint NOT NULL, - service_id integer NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - encrypted_url character varying(255), - encrypted_url_iv character varying(255), - encrypted_api_url character varying(255), - encrypted_api_url_iv character varying(255), - encrypted_token character varying(255), - encrypted_token_iv character varying(255), - closed_status_id character varying(5), - project_identifier_code character varying(100) -); - -CREATE SEQUENCE open_project_tracker_data_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE open_project_tracker_data_id_seq OWNED BY open_project_tracker_data.id; - CREATE TABLE operations_feature_flag_scopes ( id bigint NOT NULL, feature_flag_id bigint NOT NULL, @@ -21663,8 +21639,6 @@ ALTER TABLE ONLY oauth_openid_requests ALTER COLUMN id SET DEFAULT nextval('oaut ALTER TABLE ONLY onboarding_progresses ALTER COLUMN id SET DEFAULT nextval('onboarding_progresses_id_seq'::regclass); -ALTER TABLE ONLY open_project_tracker_data ALTER COLUMN id SET DEFAULT nextval('open_project_tracker_data_id_seq'::regclass); - ALTER TABLE ONLY operations_feature_flag_scopes ALTER COLUMN id SET DEFAULT nextval('operations_feature_flag_scopes_id_seq'::regclass); ALTER TABLE ONLY operations_feature_flags ALTER COLUMN id SET DEFAULT nextval('operations_feature_flags_id_seq'::regclass); @@ -23419,9 +23393,6 @@ ALTER TABLE ONLY oauth_openid_requests ALTER TABLE ONLY onboarding_progresses ADD CONSTRAINT onboarding_progresses_pkey PRIMARY KEY (id); -ALTER TABLE ONLY open_project_tracker_data - ADD CONSTRAINT open_project_tracker_data_pkey PRIMARY KEY (id); - ALTER TABLE ONLY operations_feature_flag_scopes ADD CONSTRAINT operations_feature_flag_scopes_pkey PRIMARY KEY (id); @@ -26608,8 +26579,6 @@ CREATE UNIQUE INDEX index_onboarding_progresses_on_namespace_id ON onboarding_pr CREATE INDEX index_oncall_shifts_on_rotation_id_and_starts_at_and_ends_at ON incident_management_oncall_shifts USING btree (rotation_id, starts_at, ends_at); -CREATE INDEX index_open_project_tracker_data_on_service_id ON open_project_tracker_data USING btree (service_id); - CREATE INDEX index_operations_feature_flags_issues_on_issue_id ON operations_feature_flags_issues USING btree (issue_id); CREATE UNIQUE INDEX index_operations_feature_flags_on_project_id_and_iid ON operations_feature_flags USING btree (project_id, iid); @@ -29724,9 +29693,6 @@ ALTER TABLE ONLY bulk_import_failures ALTER TABLE ONLY group_wiki_repositories ADD CONSTRAINT fk_rails_19755e374b FOREIGN KEY (shard_id) REFERENCES shards(id) ON DELETE RESTRICT; -ALTER TABLE ONLY open_project_tracker_data - ADD CONSTRAINT fk_rails_1987546e48 FOREIGN KEY (service_id) REFERENCES integrations(id) ON DELETE CASCADE; - ALTER TABLE ONLY gpg_signatures ADD CONSTRAINT fk_rails_19d4f1c6f9 FOREIGN KEY (gpg_key_subkey_id) REFERENCES gpg_key_subkeys(id) ON DELETE SET NULL; diff --git a/doc/user/admin_area/index.md b/doc/user/admin_area/index.md index 6457b4df2ff..4de2397706b 100644 --- a/doc/user/admin_area/index.md +++ b/doc/user/admin_area/index.md @@ -107,6 +107,22 @@ You can combine the filter options. For example, to list only public projects wi 1. Click the **Public** tab. 1. Enter `score` in the **Filter by name...** input box. +#### Deleted projects **(PREMIUM SELF)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37014) in GitLab 13.3. + +When delayed project deletion is [enabled for a group](../group/index.md#enable-delayed-project-removal), +projects within that group are not deleted immediately, but only after a delay. To access a list of all projects that are pending deletion: + +1. On the top bar, select **Menu > Projects > Explore projects**. +1. Select the **Deleted projects** tab. + +Listed for each project is: + +- The time the project was marked for deletion. +- The time the project is scheduled for final deletion. +- A **Restore** link to stop the project being eventually deleted. + ### Administering Users You can administer all users in the GitLab instance from the Admin Area's Users page: diff --git a/lib/declarative_enum.rb b/lib/declarative_enum.rb index 8dea9d6130b..7875e0ba4f3 100644 --- a/lib/declarative_enum.rb +++ b/lib/declarative_enum.rb @@ -15,9 +15,9 @@ # TEXT # # define do -# acceptable_risk value: 0, description: 'The vulnerability is known but is considered to be an acceptable business risk.' -# false_positive value: 1, description: 'An error in reporting the presence of a vulnerability in a system when the vulnerability is not present.' -# used_in_tests value: 2, description: 'The finding is not a vulnerability because it is part of a test or is test data.' +# acceptable_risk value: 0, description: N_('The vulnerability is known but is considered to be an acceptable business risk.') +# false_positive value: 1, description: N_('An error in reporting the presence of a vulnerability in a system when the vulnerability is not present.') +# used_in_tests value: 2, description: N_('The finding is not a vulnerability because it is part of a test or is test data.') # end # # Then we can use this module to register enums for our Active Record models like so, @@ -63,6 +63,19 @@ module DeclarativeEnum @description end + def values + definition.transform_values { |definition| definition[:value] } + end + + # Return list of dynamically translated descriptions. + # + # It is required to define descriptions with `N_(...)`. + # + # See https://github.com/grosser/fast_gettext#n_-and-nn_-make-dynamic-translations-available-to-the-parser + def translated_descriptions + definition.transform_values { |definition| _(definition[:description]) } + end + def define(&block) raise LocalJumpError, 'No block given' unless block diff --git a/lib/gitlab/database/gitlab_schemas.yml b/lib/gitlab/database/gitlab_schemas.yml index b1cae5e8eee..66157e998a0 100644 --- a/lib/gitlab/database/gitlab_schemas.yml +++ b/lib/gitlab/database/gitlab_schemas.yml @@ -323,7 +323,6 @@ oauth_access_tokens: :gitlab_main oauth_applications: :gitlab_main oauth_openid_requests: :gitlab_main onboarding_progresses: :gitlab_main -open_project_tracker_data: :gitlab_main operations_feature_flags_clients: :gitlab_main operations_feature_flag_scopes: :gitlab_main operations_feature_flags: :gitlab_main diff --git a/lib/sidebars/groups/menus/invite_team_members_menu.rb b/lib/sidebars/groups/menus/invite_team_members_menu.rb new file mode 100644 index 00000000000..0779b696061 --- /dev/null +++ b/lib/sidebars/groups/menus/invite_team_members_menu.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Sidebars + module Groups + module Menus + class InviteTeamMembersMenu < ::Sidebars::Menu + override :title + def title + s_('InviteMember|Invite members') + end + + override :render? + def render? + can?(context.current_user, :admin_group_member, context.group) && all_valid_members.size <= 1 + end + + override :menu_partial + def menu_partial + 'groups/invite_members_side_nav_link' + end + + override :menu_partial_options + def menu_partial_options + { + group: context.group, + title: title, + sidebar_menu: self + } + end + + override :extra_nav_link_html_options + def extra_nav_link_html_options + { + 'data-test-id': 'side-nav-invite-members' + } + end + + private + + def all_valid_members + GroupMembersFinder.new(context.group, context.current_user).execute + end + end + end + end +end diff --git a/lib/sidebars/groups/panel.rb b/lib/sidebars/groups/panel.rb index 185e49938ef..463c2571b14 100644 --- a/lib/sidebars/groups/panel.rb +++ b/lib/sidebars/groups/panel.rb @@ -15,12 +15,22 @@ module Sidebars add_menu(Sidebars::Groups::Menus::PackagesRegistriesMenu.new(context)) add_menu(Sidebars::Groups::Menus::CustomerRelationsMenu.new(context)) add_menu(Sidebars::Groups::Menus::SettingsMenu.new(context)) + add_invite_members_menu end override :aria_label def aria_label context.group.subgroup? ? _('Subgroup navigation') : _('Group navigation') end + + private + + def add_invite_members_menu + experiment(:invite_members_in_side_nav, group: context.group) do |e| + e.control {} + e.candidate { add_menu(Sidebars::Groups::Menus::InviteTeamMembersMenu.new(context)) } + end + end end end end diff --git a/lib/sidebars/panel.rb b/lib/sidebars/panel.rb index 75b3ba65729..e8c02a2d707 100644 --- a/lib/sidebars/panel.rb +++ b/lib/sidebars/panel.rb @@ -4,6 +4,7 @@ module Sidebars class Panel extend ::Gitlab::Utils::Override include ::Sidebars::Concerns::PositionableList + include Gitlab::Experiment::Dsl attr_reader :context, :scope_menu, :hidden_menu diff --git a/lib/sidebars/projects/menus/invite_team_members_menu.rb b/lib/sidebars/projects/menus/invite_team_members_menu.rb new file mode 100644 index 00000000000..0db49f1e12a --- /dev/null +++ b/lib/sidebars/projects/menus/invite_team_members_menu.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Sidebars + module Projects + module Menus + class InviteTeamMembersMenu < ::Sidebars::Menu + override :title + def title + s_('InviteMember|Invite members') + end + + override :render? + def render? + can?(context.current_user, :admin_project_member, context.project) && all_valid_members.size <= 1 + end + + override :menu_partial + def menu_partial + 'projects/invite_members_side_nav_link' + end + + override :menu_partial_options + def menu_partial_options + { + project: context.project, + title: title, + sidebar_menu: self + } + end + + override :extra_nav_link_html_options + def extra_nav_link_html_options + { + 'data-test-id': 'side-nav-invite-members' + } + end + + private + + def all_valid_members + MembersFinder.new(context.project, context.current_user) + .execute(include_relations: [:inherited, :direct, :invited_groups]) + end + end + end + end +end diff --git a/lib/sidebars/projects/panel.rb b/lib/sidebars/projects/panel.rb index 374662162b5..8fbd71c1543 100644 --- a/lib/sidebars/projects/panel.rb +++ b/lib/sidebars/projects/panel.rb @@ -36,6 +36,14 @@ module Sidebars add_menu(Sidebars::Projects::Menus::ExternalWikiMenu.new(context)) add_menu(Sidebars::Projects::Menus::SnippetsMenu.new(context)) add_menu(Sidebars::Projects::Menus::SettingsMenu.new(context)) + add_invite_members_menu + end + + def add_invite_members_menu + experiment(:invite_members_in_side_nav, group: context.project.group) do |e| + e.control {} + e.candidate { add_menu(Sidebars::Projects::Menus::InviteTeamMembersMenu.new(context)) } + end end def confluence_or_wiki_menu diff --git a/locale/gitlab.pot b/locale/gitlab.pot index cca6b77fddb..009b576ffad 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19597,6 +19597,9 @@ msgstr "" msgid "Iterations|Title" msgstr "" +msgid "Iterations|Unable to find iteration cadence." +msgstr "" + msgid "Iterations|Unable to find iteration." msgstr "" diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index aebe13c39fe..521b4cd4002 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -66,7 +66,6 @@ RSpec.describe 'Database schema' do oauth_access_grants: %w[resource_owner_id application_id], oauth_access_tokens: %w[resource_owner_id application_id], oauth_applications: %w[owner_id], - open_project_tracker_data: %w[closed_status_id], packages_build_infos: %w[pipeline_id], packages_package_file_build_infos: %w[pipeline_id], product_analytics_events_experimental: %w[event_id txn_id user_id], diff --git a/spec/features/contextual_sidebar_spec.rb b/spec/features/contextual_sidebar_spec.rb index 39881a28b11..29c7e0ddd21 100644 --- a/spec/features/contextual_sidebar_spec.rb +++ b/spec/features/contextual_sidebar_spec.rb @@ -3,35 +3,110 @@ require 'spec_helper' RSpec.describe 'Contextual sidebar', :js do - let_it_be(:project) { create(:project) } + context 'when context is a project' do + let_it_be(:project) { create(:project) } - let(:user) { project.owner } + let(:user) { project.owner } - before do - sign_in(user) + before do + sign_in(user) + end - visit project_path(project) - end + context 'when analyzing the menu' do + before do + visit project_path(project) + end + + it 'shows flyout navs when collapsed or expanded apart from on the active item when expanded', :aggregate_failures do + expect(page).not_to have_selector('.js-sidebar-collapsed') + + find('.rspec-link-pipelines').hover + + expect(page).to have_selector('.is-showing-fly-out') + + find('.rspec-project-link').hover + + expect(page).not_to have_selector('.is-showing-fly-out') + + find('.rspec-toggle-sidebar').click + + find('.rspec-link-pipelines').hover + + expect(page).to have_selector('.is-showing-fly-out') - it 'shows flyout navs when collapsed or expanded apart from on the active item when expanded', :aggregate_failures do - expect(page).not_to have_selector('.js-sidebar-collapsed') + find('.rspec-project-link').hover + + expect(page).to have_selector('.is-showing-fly-out') + end + end + + context 'with invite_members_in_side_nav experiment', :experiment do + it 'allows opening of modal for the candidate experience' do + stub_experiments(invite_members_in_side_nav: :candidate) + expect(experiment(:invite_members_in_side_nav)).to track(:assignment) + .with_context(group: project.group) + .on_next_instance + + visit project_path(project) + + page.within '[data-test-id="side-nav-invite-members"' do + find('[data-test-id="invite-members-button"').click + end + + expect(page).to have_content("You're inviting members to the") + end + + it 'does not have invite members link in side nav for the control experience' do + stub_experiments(invite_members_in_side_nav: :control) + expect(experiment(:invite_members_in_side_nav)).to track(:assignment) + .with_context(group: project.group) + .on_next_instance + + visit project_path(project) + + expect(page).not_to have_css('[data-test-id="side-nav-invite-members"') + end + end + end - find('.rspec-link-pipelines').hover + context 'when context is a group' do + let_it_be(:user) { create(:user) } + let_it_be(:group) do + create(:group).tap do |g| + g.add_owner(user) + end + end - expect(page).to have_selector('.is-showing-fly-out') + before do + sign_in(user) + end - find('.rspec-project-link').hover + context 'with invite_members_in_side_nav experiment', :experiment do + it 'allows opening of modal for the candidate experience' do + stub_experiments(invite_members_in_side_nav: :candidate) + expect(experiment(:invite_members_in_side_nav)).to track(:assignment) + .with_context(group: group) + .on_next_instance - expect(page).not_to have_selector('.is-showing-fly-out') + visit group_path(group) - find('.rspec-toggle-sidebar').click + page.within '[data-test-id="side-nav-invite-members"' do + find('[data-test-id="invite-members-button"').click + end - find('.rspec-link-pipelines').hover + expect(page).to have_content("You're inviting members to the") + end - expect(page).to have_selector('.is-showing-fly-out') + it 'does not have invite members link in side nav for the control experience' do + stub_experiments(invite_members_in_side_nav: :control) + expect(experiment(:invite_members_in_side_nav)).to track(:assignment) + .with_context(group: group) + .on_next_instance - find('.rspec-project-link').hover + visit group_path(group) - expect(page).to have_selector('.is-showing-fly-out') + expect(page).not_to have_css('[data-test-id="side-nav-invite-members"') + end + end end end diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index b2ebb9e4a47..3fce23f854c 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -1,8 +1,9 @@ -import { GlButton, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import eventHub from '~/invite_members/event_hub'; +import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '~/invite_members/constants'; jest.mock('~/experimentation/experiment_tracking'); @@ -15,6 +16,7 @@ let findButton; const triggerComponent = { button: GlButton, anchor: GlLink, + 'side-nav': GlLink, }; const createComponent = (props = {}) => { @@ -27,9 +29,23 @@ const createComponent = (props = {}) => { }); }; -describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement) => { - triggerProps = { triggerElement, triggerSource }; - findButton = () => wrapper.findComponent(triggerComponent[triggerElement]); +const triggerItems = [ + { + triggerElement: TRIGGER_ELEMENT_BUTTON, + }, + { + triggerElement: 'anchor', + }, + { + triggerElement: TRIGGER_ELEMENT_SIDE_NAV, + icon: 'plus', + }, +]; + +describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => { + triggerProps = { ...triggerItem, triggerSource }; + + findButton = () => wrapper.findComponent(triggerComponent[triggerItem.triggerElement]); afterEach(() => { wrapper.destroy(); @@ -91,3 +107,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement }); }); }); + +describe('side-nav with icon', () => { + it('includes the specified icon with correct size when triggerElement is link', () => { + const findIcon = () => wrapper.findComponent(GlIcon); + + createComponent({ triggerElement: TRIGGER_ELEMENT_SIDE_NAV, icon: 'plus' }); + + expect(findIcon().exists()).toBe(true); + expect(findIcon().props('name')).toBe('plus'); + }); +}); diff --git a/spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb b/spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb new file mode 100644 index 00000000000..a79e5182f45 --- /dev/null +++ b/spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sidebars::Groups::Menus::InviteTeamMembersMenu do + let_it_be(:owner) { create(:user) } + let_it_be(:guest) { create(:user) } + let_it_be(:group) do + build(:group).tap do |g| + g.add_owner(owner) + end + end + + let(:context) { Sidebars::Groups::Context.new(current_user: owner, container: group) } + + subject(:invite_menu) { described_class.new(context) } + + context 'when the group is viewed by an owner of the group' do + describe '#render?' do + it 'renders the Invite team members link' do + expect(invite_menu.render?).to eq(true) + end + + context 'when the group already has at least 2 members' do + before do + group.add_guest(guest) + end + + it 'does not render the link' do + expect(invite_menu.render?).to eq(false) + end + end + end + + describe '#title' do + it 'displays the correct Invite team members text for the link in the side nav' do + expect(invite_menu.title).to eq('Invite members') + end + end + end + + context 'when the group is viewed by a guest user without admin permissions' do + let(:context) { Sidebars::Groups::Context.new(current_user: guest, container: group) } + + before do + group.add_guest(guest) + end + + describe '#render?' do + it 'does not render the link' do + expect(subject.render?).to eq(false) + end + end + end +end diff --git a/spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb b/spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb new file mode 100644 index 00000000000..df9b260d211 --- /dev/null +++ b/spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sidebars::Projects::Menus::InviteTeamMembersMenu do + let_it_be(:project) { create(:project) } + let_it_be(:guest) { create(:user) } + + let(:context) { Sidebars::Projects::Context.new(current_user: owner, container: project) } + + subject(:invite_menu) { described_class.new(context) } + + context 'when the project is viewed by an owner of the group' do + let(:owner) { project.owner } + + describe '#render?' do + it 'renders the Invite team members link' do + expect(invite_menu.render?).to eq(true) + end + + context 'when the project already has at least 2 members' do + before do + project.add_guest(guest) + end + + it 'does not render the link' do + expect(invite_menu.render?).to eq(false) + end + end + end + + describe '#title' do + it 'displays the correct Invite team members text for the link in the side nav' do + expect(invite_menu.title).to eq('Invite members') + end + end + end + + context 'when the project is viewed by a guest user without admin permissions' do + let(:context) { Sidebars::Projects::Context.new(current_user: guest, container: project) } + + before do + project.add_guest(guest) + end + + describe '#render?' do + it 'does not render' do + expect(invite_menu.render?).to eq(false) + end + end + end +end |