diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js index 56aa0ea28a928028aa93623c15c57df2e49c2906..29898708a41302ddbe698b640f4db67328c0fdd4 100644 --- a/app/assets/javascripts/organizations/mock_data.js +++ b/app/assets/javascripts/organizations/mock_data.js @@ -4,6 +4,13 @@ // https://gitlab.com/gitlab-org/gitlab/-/issues/420777 // https://gitlab.com/gitlab-org/gitlab/-/issues/421441 +export const defaultOrganization = { + id: 1, + name: 'Default', + web_url: '/-/organizations/default', + avatar_url: null, +}; + export const organizations = [ { id: 'gid://gitlab/Organizations::Organization/1', diff --git a/app/assets/javascripts/super_sidebar/components/organization_switcher.vue b/app/assets/javascripts/super_sidebar/components/organization_switcher.vue new file mode 100644 index 0000000000000000000000000000000000000000..7122f147d3e2c4f60a5e92f7f2b11f6abc5f1525 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/organization_switcher.vue @@ -0,0 +1,144 @@ +<script> +import { GlDisclosureDropdown, GlAvatar, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import getCurrentUserOrganizations from '~/organizations/shared/graphql/queries/organizations.query.graphql'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { defaultOrganization } from '~/organizations/mock_data'; +import { s__ } from '~/locale'; + +export default { + AVATAR_SHAPE_OPTION_RECT, + ITEM_LOADING: { + id: 'loading', + text: 'loading', + extraAttrs: { disabled: true, class: 'gl-shadow-none!' }, + }, + ITEM_EMPTY: { + id: 'empty', + text: s__('Organization|No organizations available to switch to.'), + extraAttrs: { disabled: true, class: 'gl-shadow-none! gl-text-secondary' }, + }, + i18n: { + currentOrganization: s__('Organization|Current organization'), + switchOrganizations: s__('Organization|Switch organizations'), + }, + components: { GlDisclosureDropdown, GlAvatar, GlIcon, GlLoadingIcon }, + data() { + return { + organizations: {}, + dropdownShown: false, + }; + }, + apollo: { + organizations: { + query: getCurrentUserOrganizations, + update(data) { + return data.currentUser.organizations; + }, + skip() { + return !this.dropdownShown; + }, + error() { + this.organizations = { + nodes: [], + pageInfo: {}, + }; + }, + }, + }, + computed: { + loading() { + return this.$apollo.queries.organizations.loading; + }, + currentOrganization() { + // TODO - use `gon.current_organization` when backend supports it. + // https://gitlab.com/gitlab-org/gitlab/-/issues/437095 + return defaultOrganization; + }, + nodes() { + return this.organizations.nodes || []; + }, + items() { + const currentOrganizationGroup = { + name: this.$options.i18n.currentOrganization, + items: [ + { + id: this.currentOrganization.id, + text: this.currentOrganization.name, + href: this.currentOrganization.web_url, + avatarUrl: this.currentOrganization.avatar_url, + }, + ], + }; + + if (this.loading || !this.dropdownShown) { + return [ + currentOrganizationGroup, + { + name: this.$options.i18n.switchOrganizations, + items: [this.$options.ITEM_LOADING], + }, + ]; + } + + const items = this.nodes + .map((node) => ({ + id: getIdFromGraphQLId(node.id), + text: node.name, + href: node.webUrl, + avatarUrl: node.avatarUrl, + })) + .filter((item) => item.id !== this.currentOrganization.id); + + return [ + currentOrganizationGroup, + { + name: this.$options.i18n.switchOrganizations, + items: items.length ? items : [this.$options.ITEM_EMPTY], + }, + ]; + }, + }, + methods: { + onShown() { + this.dropdownShown = true; + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown :items="items" class="gl-display-block" @shown="onShown"> + <template #toggle> + <button + class="organization-switcher-button gl-display-flex gl-align-items-center gl-gap-3 gl-p-3 gl-rounded-base gl-border-none gl-line-height-1 gl-w-full" + data-testid="toggle-button" + > + <gl-avatar + :size="24" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :entity-id="currentOrganization.id" + :entity-name="currentOrganization.name" + :src="currentOrganization.avatar_url" + /> + <span>{{ currentOrganization.name }}</span> + <gl-icon class="gl-button-icon gl-new-dropdown-chevron" name="chevron-down" /> + </button> + </template> + + <template #list-item="{ item }"> + <gl-loading-icon v-if="item.id === $options.ITEM_LOADING.id" /> + <span v-else-if="item.id === $options.ITEM_EMPTY.id">{{ item.text }}</span> + <div v-else class="gl-display-flex gl-align-items-center gl-gap-3"> + <gl-avatar + :size="24" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :entity-id="item.id" + :entity-name="item.text" + :src="item.avatarUrl" + /> + <span>{{ item.text }}</span> + </div> + </template> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index 3c8bf62ff5c55d9b96dd1b4fdd71d23797778c1c..794a8b2cea9c6039933bdc00fe57bf565dea10fe 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -6,6 +6,7 @@ import { createUserCountsManager, userCounts, } from '~/super_sidebar/user_counts_manager'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import BrandLogo from 'jh_else_ce/super_sidebar/components/brand_logo.vue'; import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants'; import CreateMenu from './create_menu.vue'; @@ -35,6 +36,8 @@ export default { SuperSidebarToggle, BrandLogo, GlIcon, + OrganizationSwitcher: () => + import(/* webpackChunkName: 'organization_switcher' */ './organization_switcher.vue'), }, i18n: { issues: __('Issues'), @@ -52,6 +55,7 @@ export default { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, + mixins: [glFeatureFlagsMixin()], inject: ['isImpersonating'], props: { hasCollapseButton: { @@ -149,6 +153,7 @@ export default { data-testid="stop-impersonation-btn" /> </div> + <organization-switcher v-if="glFeatures.uiForOrganizations" /> <div v-if="sidebarData.is_logged_in" class="gl-display-flex gl-justify-content-space-between gl-gap-2" diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 0cb35bc117bc10d415f9b91a3ce0263ef0d9c9b3..4d0b22928cc1c8d77e95ea8d16a3d59735264175 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -231,6 +231,18 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; .user-bar { background-color: var(--super-sidebar-user-bar-bg); + .organization-switcher-button { + background-color: transparent; + color: var(--super-sidebar-user-bar-button-color); + + &:active, + &:hover, + &:focus { + background-color: var(--super-sidebar-user-bar-button-hover-bg); + color: var(--super-sidebar-user-bar-button-hover-color); + } + } + .user-bar-dropdown-toggle { padding: $gl-spacing-scale-2; @include gl-border-none; diff --git a/ee/spec/features/registrations/saas/invite_flow_spec.rb b/ee/spec/features/registrations/saas/invite_flow_spec.rb index c0832f38d25ba28ccfeb4b2468febfc585cde7cd..3a0996e49410b53239b19ba543993f0151466bfa 100644 --- a/ee/spec/features/registrations/saas/invite_flow_spec.rb +++ b/ee/spec/features/registrations/saas/invite_flow_spec.rb @@ -55,7 +55,7 @@ def registers_from_invite(user:, group:) visit invite_path(invitation.raw_invite_token, invite_type: Emails::Members::INITIAL_INVITE) # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/438017 - allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(103) + allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(102) fill_in_sign_up_form(user, invite: true) end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 6420b2561dcde22f2a4e0618c3c042ecf9f98f95..9d7e73ba1f232a3951a74b0699b8ce29433021ee 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -77,6 +77,7 @@ def add_gon_variables push_frontend_feature_flag(:source_editor_toolbar) push_frontend_feature_flag(:vscode_web_ide, current_user) push_frontend_feature_flag(:key_contacts_management, current_user) + push_frontend_feature_flag(:ui_for_organizations, current_user) # To be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/399248 push_frontend_feature_flag(:remove_monitor_metrics) push_frontend_feature_flag(:custom_emoji) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d925ac87c1da8e5d054ece201b88c773deb8bc47..333b78b037def8ab01df5e6bfc7dca150a55c263 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -34232,6 +34232,9 @@ msgstr "" msgid "Organization|Create organization" msgstr "" +msgid "Organization|Current organization" +msgstr "" + msgid "Organization|Frequently visited groups" msgstr "" @@ -34256,6 +34259,9 @@ msgstr "" msgid "Organization|New organization" msgstr "" +msgid "Organization|No organizations available to switch to." +msgstr "" + msgid "Organization|Org ID" msgstr "" @@ -34319,6 +34325,9 @@ msgstr "" msgid "Organization|Select an organization" msgstr "" +msgid "Organization|Switch organizations" +msgstr "" + msgid "Organization|Unable to fetch organizations. Reload the page to try again." msgstr "" diff --git a/spec/features/nav/pinned_nav_items_spec.rb b/spec/features/nav/pinned_nav_items_spec.rb index a2428048a1ad099091c35b4492ee32459c410d86..a1137536dd5052ee64a6e6a6d4ae4549f48a9cd4 100644 --- a/spec/features/nav/pinned_nav_items_spec.rb +++ b/spec/features/nav/pinned_nav_items_spec.rb @@ -170,6 +170,7 @@ def add_pin(nav_item_title) nav_item = find("[data-testid=\"nav-item\"]", text: nav_item_title) + scroll_to(nav_item) nav_item.hover pin_button = nav_item.find("[data-testid=\"nav-item-pin\"]") pin_button.click @@ -178,6 +179,7 @@ def add_pin(nav_item_title) def remove_pin(nav_item_title) nav_item = find("[data-testid=\"nav-item\"]", text: nav_item_title) + scroll_to(nav_item) nav_item.hover unpin_button = nav_item.find("[data-testid=\"nav-item-unpin\"]") unpin_button.click diff --git a/spec/frontend/super_sidebar/components/organization_switcher_spec.js b/spec/frontend/super_sidebar/components/organization_switcher_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..094cb4baedb3c79df141ae6206ea6a5ae23b1a51 --- /dev/null +++ b/spec/frontend/super_sidebar/components/organization_switcher_spec.js @@ -0,0 +1,148 @@ +import { GlAvatar, GlDisclosureDropdown, GlLoadingIcon } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import OrganizationSwitcher from '~/super_sidebar/components/organization_switcher.vue'; +import { + defaultOrganization as currentOrganization, + organizations as nodes, + pageInfo, + pageInfoEmpty, +} from '~/organizations/mock_data'; +import organizationsQuery from '~/organizations/shared/graphql/queries/organizations.query.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +Vue.use(VueApollo); + +describe('OrganizationSwitcher', () => { + let wrapper; + let mockApollo; + + const [, secondOrganization, thirdOrganization] = nodes; + + const organizations = { + nodes, + pageInfo, + }; + + const successHandler = jest.fn().mockResolvedValue({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + organizations, + }, + }, + }); + + const createComponent = (handler = successHandler) => { + mockApollo = createMockApollo([[organizationsQuery, handler]]); + + wrapper = mountExtended(OrganizationSwitcher, { + apolloProvider: mockApollo, + }); + }; + + const findDropdownItemByIndex = (index) => + wrapper.findAllByTestId('disclosure-dropdown-item').at(index); + const showDropdown = () => wrapper.findComponent(GlDisclosureDropdown).vm.$emit('shown'); + + afterEach(() => { + mockApollo = null; + }); + + it('renders disclosure dropdown with current organization selected', () => { + createComponent(); + + const toggleButton = wrapper.findByTestId('toggle-button'); + const dropdownItem = findDropdownItemByIndex(0); + + expect(toggleButton.text()).toContain(currentOrganization.name); + expect(toggleButton.findComponent(GlAvatar).props()).toMatchObject({ + src: currentOrganization.avatar_url, + entityId: currentOrganization.id, + entityName: currentOrganization.name, + }); + expect(dropdownItem.text()).toContain(currentOrganization.name); + expect(dropdownItem.findComponent(GlAvatar).props()).toMatchObject({ + src: currentOrganization.avatar_url, + entityId: currentOrganization.id, + entityName: currentOrganization.name, + }); + }); + + it('does not call GraphQL query', () => { + createComponent(); + + expect(successHandler).not.toHaveBeenCalled(); + }); + + describe('when dropdown is shown', () => { + it('calls GraphQL query and renders organizations that are available to switch to', async () => { + createComponent(); + showDropdown(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + + await waitForPromises(); + + expect(findDropdownItemByIndex(1).text()).toContain(secondOrganization.name); + expect(findDropdownItemByIndex(1).element.firstChild.getAttribute('href')).toBe( + secondOrganization.webUrl, + ); + expect(findDropdownItemByIndex(1).findComponent(GlAvatar).props()).toMatchObject({ + src: secondOrganization.avatarUrl, + entityId: getIdFromGraphQLId(secondOrganization.id), + entityName: secondOrganization.name, + }); + + expect(findDropdownItemByIndex(2).text()).toContain(thirdOrganization.name); + expect(findDropdownItemByIndex(2).element.firstChild.getAttribute('href')).toBe( + thirdOrganization.webUrl, + ); + expect(findDropdownItemByIndex(2).findComponent(GlAvatar).props()).toMatchObject({ + src: thirdOrganization.avatarUrl, + entityId: getIdFromGraphQLId(thirdOrganization.id), + entityName: thirdOrganization.name, + }); + }); + + describe('when there are no organizations to switch to', () => { + beforeEach(async () => { + createComponent( + jest.fn().mockResolvedValue({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + organizations: { + nodes: [], + pageInfo: pageInfoEmpty, + }, + }, + }, + }), + ); + showDropdown(); + await waitForPromises(); + }); + + it('renders empty message', () => { + expect(findDropdownItemByIndex(1).text()).toBe('No organizations available to switch to.'); + }); + }); + + describe('when there is an error fetching organizations', () => { + beforeEach(async () => { + createComponent(jest.fn().mockRejectedValue()); + showDropdown(); + await waitForPromises(); + }); + + it('renders empty message', () => { + expect(findDropdownItemByIndex(1).text()).toBe('No organizations available to switch to.'); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js index 27d65f27007430b356b84b8854ed02da4bf2c523..fa2c6fdf1659928113957968fd83f6c927d1a632 100644 --- a/spec/frontend/super_sidebar/components/user_bar_spec.js +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -9,16 +9,20 @@ import UserMenu from '~/super_sidebar/components/user_menu.vue'; import SearchModal from '~/super_sidebar/components/global_search/components/global_search.vue'; import BrandLogo from 'jh_else_ce/super_sidebar/components/brand_logo.vue'; import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue'; +import OrganizationSwitcher from '~/super_sidebar/components/organization_switcher.vue'; import UserBar from '~/super_sidebar/components/user_bar.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; import { userCounts } from '~/super_sidebar/user_counts_manager'; +import { stubComponent } from 'helpers/stub_component'; import { sidebarData as mockSidebarData, loggedOutSidebarData } from '../mock_data'; import { MOCK_DEFAULT_SEARCH_OPTIONS } from './global_search/mock_data'; describe('UserBar component', () => { let wrapper; + const OrganizationSwitcherStub = stubComponent(OrganizationSwitcher); + const findCreateMenu = () => wrapper.findComponent(CreateMenu); const findUserMenu = () => wrapper.findComponent(UserMenu); const findIssuesCounter = () => wrapper.findByTestId('issues-shortcut-button'); @@ -30,6 +34,7 @@ describe('UserBar component', () => { const findSearchButton = () => wrapper.findByTestId('super-sidebar-search-button'); const findSearchModal = () => wrapper.findComponent(SearchModal); const findStopImpersonationButton = () => wrapper.findByTestId('stop-impersonation-btn'); + const findOrganizationSwitcher = () => wrapper.findComponent(OrganizationSwitcherStub); Vue.use(Vuex); @@ -56,6 +61,9 @@ describe('UserBar component', () => { GlTooltip: createMockDirective('gl-tooltip'), }, store, + stubs: { + OrganizationSwitcher: OrganizationSwitcherStub, + }, }); }; @@ -252,4 +260,22 @@ describe('UserBar component', () => { expect(findTodosCounter().exists()).toBe(false); }); }); + + describe('when `ui_for_organizations` feature flag is enabled', () => { + it('renders `OrganizationSwitcher component', async () => { + createWrapper({ provideOverrides: { glFeatures: { uiForOrganizations: true } } }); + await waitForPromises(); + + expect(findOrganizationSwitcher().exists()).toBe(true); + }); + }); + + describe('when `ui_for_organizations` feature flag is disabled', () => { + it('renders `OrganizationSwitcher component', async () => { + createWrapper(); + await waitForPromises(); + + expect(findOrganizationSwitcher().exists()).toBe(false); + }); + }); });