Commit db589f28 authored by peterhegman's avatar peterhegman

Add the ability to deep link into group/project member tabs

Update query string when group/project member tabs are changed so tabs
can be deep linked into.

Changelog: added
parent f0ee3a81
......@@ -19,6 +19,11 @@ export default {
type: String,
required: true,
},
tabQueryParamValue: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState({
......@@ -55,6 +60,6 @@ export default {
errorMessage
}}</gl-alert>
<filter-sort-container />
<members-table />
<members-table :tab-query-param-value="tabQueryParamValue" />
</div>
</template>
......@@ -5,7 +5,11 @@ import { getParameterByName } from '~/lib/utils/common_utils';
// eslint-disable-next-line import/no-deprecated
import { setUrlParams, urlParamsToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { SEARCH_TOKEN_TYPE, SORT_PARAM } from '~/members/constants';
import {
SEARCH_TOKEN_TYPE,
SORT_QUERY_PARAM_NAME,
ACTIVE_TAB_QUERY_PARAM_NAME,
} from '~/members/constants';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
......@@ -118,10 +122,15 @@ export default {
return accumulator;
}, {});
const sortParam = getParameterByName(SORT_PARAM);
const sortParamValue = getParameterByName(SORT_QUERY_PARAM_NAME);
const activeTabParamValue = getParameterByName(ACTIVE_TAB_QUERY_PARAM_NAME);
window.location.href = setUrlParams(
{ ...params, ...(sortParam && { sort: sortParam }) },
{
...params,
...(sortParamValue && { [SORT_QUERY_PARAM_NAME]: sortParamValue }),
...(activeTabParamValue && { [ACTIVE_TAB_QUERY_PARAM_NAME]: activeTabParamValue }),
},
window.location.href,
true,
);
......
......@@ -4,14 +4,15 @@ import { mapState } from 'vuex';
// eslint-disable-next-line import/no-deprecated
import { urlParamsToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { MEMBER_TYPES } from '../constants';
import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME } from '../constants';
import MembersApp from './app.vue';
const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0;
export default {
name: 'MembersTabs',
tabs: [
ACTIVE_TAB_QUERY_PARAM_NAME,
TABS: [
{
namespace: MEMBER_TYPES.user,
title: __('Members'),
......@@ -20,19 +21,21 @@ export default {
namespace: MEMBER_TYPES.group,
title: __('Groups'),
attrs: { 'data-qa-selector': 'groups_list_tab' },
queryParamValue: TAB_QUERY_PARAM_VALUES.group,
},
{
namespace: MEMBER_TYPES.invite,
title: __('Invited'),
canManageMembersPermissionsRequired: true,
queryParamValue: TAB_QUERY_PARAM_VALUES.invite,
},
{
namespace: MEMBER_TYPES.accessRequest,
title: __('Access requests'),
canManageMembersPermissionsRequired: true,
queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest,
},
],
urlParams: [],
components: { MembersApp, GlTabs, GlTab, GlBadge },
inject: ['canManageMembers'],
data() {
......@@ -60,29 +63,18 @@ export default {
return Object.keys(urlParamsToObject(window.location.search));
},
activeTabIndexCalculatedFromUrlParams() {
return this.$options.tabs.findIndex(({ namespace }) => {
return this.$options.TABS.findIndex(({ namespace }) => {
return this.getTabUrlParams(namespace).some((urlParam) =>
this.urlParams.includes(urlParam),
);
});
},
},
created() {
if (this.activeTabIndexCalculatedFromUrlParams === -1) {
return;
}
this.selectedTabIndex = this.activeTabIndexCalculatedFromUrlParams;
},
methods: {
getTabUrlParams(namespace) {
const state = this.$store.state[namespace];
const urlParams = [];
if (state?.pagination?.paramName) {
urlParams.push(state.pagination.paramName);
}
if (state?.filteredSearchBar?.searchParam) {
urlParams.push(state.filteredSearchBar.searchParam);
}
......@@ -112,14 +104,23 @@ export default {
</script>
<template>
<gl-tabs v-model="selectedTabIndex">
<template v-for="(tab, index) in $options.tabs">
<gl-tab v-if="showTab(tab, index)" :key="tab.namespace" :title-link-attributes="tab.attrs">
<gl-tabs
v-model="selectedTabIndex"
sync-active-tab-with-query-params
:query-param-name="$options.ACTIVE_TAB_QUERY_PARAM_NAME"
>
<template v-for="(tab, index) in $options.TABS">
<gl-tab
v-if="showTab(tab, index)"
:key="tab.namespace"
:title-link-attributes="tab.attrs"
:query-param-value="tab.queryParamValue"
>
<template slot="title">
<span>{{ tab.title }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge">{{ getTabCount(tab) }}</gl-badge>
</template>
<members-app :namespace="tab.namespace" />
<members-app :namespace="tab.namespace" :tab-query-param-value="tab.queryParamValue" />
</gl-tab>
</template>
</gl-tabs>
......
......@@ -5,7 +5,7 @@ import MembersTableCell from 'ee_else_ce/members/components/table/members_table_
import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
import { FIELDS } from '../../constants';
import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME } from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
import CreatedAt from './created_at.vue';
import ExpirationDatepicker from './expiration_datepicker.vue';
......@@ -34,6 +34,13 @@ export default {
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
inject: ['namespace', 'currentUserId'],
props: {
tabQueryParamValue: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState({
members(state) {
......@@ -112,7 +119,15 @@ export default {
paginationLinkGenerator(page) {
const { params = {}, paramName } = this.pagination;
return mergeUrlParams({ ...params, [paramName]: page }, window.location.href);
return mergeUrlParams(
{
...params,
[ACTIVE_TAB_QUERY_PARAM_NAME]:
this.tabQueryParamValue !== '' ? this.tabQueryParamValue : null,
[paramName]: page,
},
window.location.href,
);
},
},
};
......
......@@ -89,6 +89,12 @@ export const MEMBER_TYPES = {
accessRequest: 'accessRequest',
};
export const TAB_QUERY_PARAM_VALUES = {
group: 'groups',
invite: 'invited',
accessRequest: 'access_requests',
};
export const DAYS_TO_EXPIRE_SOON = 7;
export const LEAVE_MODAL_ID = 'member-leave-modal';
......@@ -97,7 +103,8 @@ export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id';
export const SEARCH_TOKEN_TYPE = 'filtered-search-term';
export const SORT_PARAM = 'sort';
export const SORT_QUERY_PARAM_NAME = 'sort';
export const ACTIVE_TAB_QUERY_PARAM_NAME = 'tab';
export const MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level';
......
......@@ -5,7 +5,8 @@ import Vuex from 'vuex';
import * as commonUtils from '~/lib/utils/common_utils';
import MembersApp from '~/members/components/app.vue';
import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue';
import { MEMBER_TYPES } from '~/members/constants';
import MembersTable from '~/members/components/table/members_table.vue';
import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES } from '~/members/constants';
import { RECEIVE_MEMBER_ROLE_ERROR, HIDE_ERROR } from '~/members/store/mutation_types';
import mutations from '~/members/store/mutations';
......@@ -19,7 +20,7 @@ describe('MembersApp', () => {
const createComponent = (state = {}, options = {}) => {
store = new Vuex.Store({
modules: {
[MEMBER_TYPES.user]: {
[MEMBER_TYPES.group]: {
namespaced: true,
state: {
showError: true,
......@@ -34,7 +35,8 @@ describe('MembersApp', () => {
wrapper = shallowMount(MembersApp, {
localVue,
propsData: {
namespace: MEMBER_TYPES.user,
namespace: MEMBER_TYPES.group,
tabQueryParamValue: TAB_QUERY_PARAM_VALUES.group,
},
store,
...options,
......@@ -57,7 +59,7 @@ describe('MembersApp', () => {
it('renders and scrolls to error alert', async () => {
createComponent({ showError: false, errorMessage: '' });
store.commit(`${MEMBER_TYPES.user}/${RECEIVE_MEMBER_ROLE_ERROR}`, {
store.commit(`${MEMBER_TYPES.group}/${RECEIVE_MEMBER_ROLE_ERROR}`, {
error: new Error('Network Error'),
});
......@@ -77,7 +79,7 @@ describe('MembersApp', () => {
it('does not render and scroll to error alert', async () => {
createComponent();
store.commit(`${MEMBER_TYPES.user}/${HIDE_ERROR}`);
store.commit(`${MEMBER_TYPES.group}/${HIDE_ERROR}`);
await nextTick();
......@@ -103,4 +105,13 @@ describe('MembersApp', () => {
expect(findFilterSortContainer().exists()).toBe(true);
});
it('renders `MembersTable` component and passes `tabQueryParamValue` prop', () => {
createComponent();
const membersTableComponent = wrapper.findComponent(MembersTable);
expect(membersTableComponent.exists()).toBe(true);
expect(membersTableComponent.props('tabQueryParamValue')).toBe(TAB_QUERY_PARAM_VALUES.group);
});
});
......@@ -216,5 +216,17 @@ describe('MembersFilteredSearchBar', () => {
'https://localhost/?two_factor=enabled&search=foobar&sort=name_asc',
);
});
it('adds active tab query param', () => {
window.location.search = '?tab=invited';
createComponent();
findFilteredSearchBar().vm.$emit('onFilter', [
{ type: 'filtered-search-term', value: { data: 'foobar' } },
]);
expect(window.location.href).toBe('https://localhost/?search=foobar&tab=invited');
});
});
});
import { GlTabs } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import MembersApp from '~/members/components/app.vue';
import MembersTabs from '~/members/components/members_tabs.vue';
import { MEMBER_TYPES } from '~/members/constants';
import {
MEMBER_TYPES,
TAB_QUERY_PARAM_VALUES,
ACTIVE_TAB_QUERY_PARAM_NAME,
} from '~/members/constants';
import { pagination } from '../mock_data';
describe('MembersTabs', () => {
......@@ -93,6 +98,18 @@ describe('MembersTabs', () => {
wrapper.destroy();
});
it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', async () => {
await createComponent();
const glTabsComponent = wrapper.findComponent(GlTabs);
expect(glTabsComponent.exists()).toBe(true);
expect(glTabsComponent.props()).toMatchObject({
syncActiveTabWithQueryParams: true,
queryParamName: ACTIVE_TAB_QUERY_PARAM_NAME,
});
});
describe('when tabs have a count', () => {
it('renders tabs with count', async () => {
await createComponent();
......@@ -106,7 +123,7 @@ describe('MembersTabs', () => {
expect(findActiveTab().text()).toContain('Members');
});
it('renders `MembersApp` and passes `namespace` prop', async () => {
it('renders `MembersApp` and passes `namespace` and `tabQueryParamValue` props', async () => {
await createComponent();
const membersApps = wrapper.findAllComponents(MembersApp).wrappers;
......@@ -115,6 +132,10 @@ describe('MembersTabs', () => {
expect(membersApps[1].props('namespace')).toBe(MEMBER_TYPES.group);
expect(membersApps[2].props('namespace')).toBe(MEMBER_TYPES.invite);
expect(membersApps[3].props('namespace')).toBe(MEMBER_TYPES.accessRequest);
expect(membersApps[1].props('tabQueryParamValue')).toBe(TAB_QUERY_PARAM_VALUES.group);
expect(membersApps[2].props('tabQueryParamValue')).toBe(TAB_QUERY_PARAM_VALUES.invite);
expect(membersApps[3].props('tabQueryParamValue')).toBe(TAB_QUERY_PARAM_VALUES.accessRequest);
});
});
......@@ -127,56 +148,16 @@ describe('MembersTabs', () => {
expect(findTabByText('Invited')).toBeUndefined();
expect(findTabByText('Access requests')).toBeUndefined();
});
});
describe('when url param matches `filteredSearchBar.searchParam`', () => {
beforeEach(() => {
window.location.search = '?search_groups=foo+bar';
});
const expectGroupsTabActive = () => {
expect(findActiveTab().text()).toContain('Groups');
};
describe('when tab has a count', () => {
it('sets tab that corresponds to search param as active tab', async () => {
await createComponent();
expectGroupsTabActive();
describe('when url param matches `filteredSearchBar.searchParam`', () => {
beforeEach(() => {
window.location.search = '?search_groups=foo+bar';
});
});
describe('when tab does not have a count', () => {
it('sets tab that corresponds to search param as active tab', async () => {
await createComponent({ totalItems: 0 });
expectGroupsTabActive();
});
});
});
describe('when url param matches `pagination.paramName`', () => {
beforeEach(() => {
window.location.search = '?invited_page=2';
});
const expectInvitedTabActive = () => {
expect(findActiveTab().text()).toContain('Invited');
};
describe('when tab has a count', () => {
it('sets tab that corresponds to pagination param as active tab', async () => {
await createComponent();
expectInvitedTabActive();
});
});
describe('when tab does not have a count', () => {
it('sets tab that corresponds to pagination param as active tab', async () => {
it('shows tab that corresponds to search param', async () => {
await createComponent({ totalItems: 0 });
expectInvitedTabActive();
expect(findTabByText('Groups')).not.toBeUndefined();
});
});
});
......
......@@ -15,7 +15,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue';
import MemberSource from '~/members/components/table/member_source.vue';
import MembersTable from '~/members/components/table/members_table.vue';
import RoleDropdown from '~/members/components/table/role_dropdown.vue';
import { MEMBER_TYPES } from '~/members/constants';
import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES } from '~/members/constants';
import * as initUserPopovers from '~/user_popovers';
import {
member as memberMock,
......@@ -34,7 +34,7 @@ describe('MembersTable', () => {
const createStore = (state = {}) => {
return new Vuex.Store({
modules: {
[MEMBER_TYPES.user]: {
[MEMBER_TYPES.invite]: {
namespaced: true,
state: {
members: [],
......@@ -54,11 +54,14 @@ describe('MembersTable', () => {
const createComponent = (state, provide = {}) => {
wrapper = mount(MembersTable, {
localVue,
propsData: {
tabQueryParamValue: TAB_QUERY_PARAM_VALUES.invite,
},
store: createStore(state),
provide: {
sourceId: 1,
currentUserId: 1,
namespace: MEMBER_TYPES.user,
namespace: MEMBER_TYPES.invite,
...provide,
},
stubs: [
......@@ -74,7 +77,7 @@ describe('MembersTable', () => {
});
};
const url = 'https://localhost/foo-bar/-/project_members';
const url = 'https://localhost/foo-bar/-/project_members?tab=invited';
const getByText = (text, options) =>
createWrapper(getByTextHelper(wrapper.element, text, options));
......@@ -92,7 +95,7 @@ describe('MembersTable', () => {
const expectCorrectLinkToPage2 = () => {
expect(findPagination().findByText('2', { selector: 'a' }).attributes('href')).toBe(
`${url}?page=2`,
`${url}&invited_members_page=2`,
);
};
......@@ -271,7 +274,7 @@ describe('MembersTable', () => {
currentPage: 1,
perPage: 5,
totalItems: 10,
paramName: 'page',
paramName: 'invited_members_page',
},
});
......@@ -279,14 +282,14 @@ describe('MembersTable', () => {
});
it('removes any url params defined as `null` in the `params` attribute', () => {
window.location = new URL(`${url}?search_groups=foo`);
window.location = new URL(`${url}&search_groups=foo`);
createComponent({
pagination: {
currentPage: 1,
perPage: 5,
totalItems: 10,
paramName: 'page',
paramName: 'invited_members_page',
params: { search_groups: null },
},
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment