Skip to content
Snippets Groups Projects
Verified Commit 76d4d5d5 authored by Peter Hegman's avatar Peter Hegman :red_circle: Committed by GitLab
Browse files

Add start of organization switcher

Allows users to switch between available organizations
parent 71a429cb
No related branches found
No related tags found
2 merge requests!144312Change service start (cut-off) date for code suggestions to March 15th,!140603Add basic organization switcher
......@@ -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',
......
<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>
......@@ -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"
......
......@@ -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;
......
......@@ -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
......
......@@ -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)
......
......@@ -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 ""
 
......@@ -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
......
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.');
});
});
});
});
......@@ -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);
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment