Commit c7e05e5a authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '276215-admin-users-show-group-count' into 'master'

Add total group count to admin users table [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!60998
parents 6a645a3b 3aa9c475
<script>
import { GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
import { GlSkeletonLoader, GlTable } from '@gitlab/ui';
import createFlash from '~/flash';
import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils';
import { s__, __ } from '~/locale';
import UserDate from '~/vue_shared/components/user_date.vue';
import getUsersGroupCountsQuery from '../graphql/queries/get_users_group_counts.query.graphql';
import UserActions from './user_actions.vue';
import UserAvatar from './user_avatar.vue';
......@@ -11,6 +14,7 @@ const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
export default {
components: {
GlSkeletonLoader,
GlTable,
UserAvatar,
UserActions,
......@@ -26,6 +30,45 @@ export default {
required: true,
},
},
data() {
return {
groupCounts: [],
};
},
apollo: {
groupCounts: {
query: getUsersGroupCountsQuery,
variables() {
return {
usernames: this.users.map((user) => user.username),
};
},
update(data) {
const nodes = data?.users?.nodes || [];
const parsedIds = convertNodeIdsFromGraphQLIds(nodes);
return parsedIds.reduce((acc, { id, groupCount }) => {
acc[id] = groupCount || 0;
return acc;
}, {});
},
error(error) {
createFlash({
message: this.$options.i18n.groupCountFetchError,
captureError: true,
error,
});
},
skip() {
return !this.users.length;
},
},
},
i18n: {
groupCountFetchError: s__(
'AdminUsers|Could not load user group counts. Please refresh the page to try again.',
),
},
fields: [
{
key: 'name',
......@@ -37,6 +80,11 @@ export default {
label: __('Projects'),
thClass: thWidthClass(10),
},
{
key: 'groupCount',
label: __('Groups'),
thClass: thWidthClass(10),
},
{
key: 'createdAt',
label: __('Created on'),
......@@ -50,7 +98,7 @@ export default {
{
key: 'settings',
label: '',
thClass: thWidthClass(20),
thClass: thWidthClass(10),
},
],
};
......@@ -77,6 +125,13 @@ export default {
<user-date :date="lastActivityOn" show-never />
</template>
<template #cell(groupCount)="{ item: { id } }">
<div :data-testid="`user-group-count-${id}`">
<gl-skeleton-loader v-if="$apollo.loading" :width="40" :lines="1" />
<span v-else>{{ groupCounts[id] }}</span>
</div>
</template>
<template #cell(projectsCount)="{ item: { id, projectsCount } }">
<div :data-testid="`user-project-count-${id}`">{{ projectsCount }}</div>
</template>
......
query getUsersGroupCounts($usernames: [String!]) {
users(usernames: $usernames) {
nodes {
id
groupCount
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import AdminUsersApp from './components/app.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => {
if (!el) {
return false;
......@@ -11,6 +19,7 @@ export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-a
return new Vue({
el,
apolloProvider,
render: (createElement) =>
createElement(AdminUsersApp, {
props: {
......
......@@ -64,8 +64,7 @@ module Types
description: 'Group memberships of the user.'
field :group_count,
resolver: Resolvers::Users::GroupCountResolver,
description: 'Group count for the user.',
feature_flag: :user_group_counts
description: 'Group count for the user.'
field :status,
type: Types::UserStatusType,
null: true,
......
---
title: Show total group counts in admin users table
merge_request: 60998
author:
type: added
---
name: user_group_counts
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44069/
rollout_issue_url:
milestone: '13.6'
type: development
group: group::compliance
default_enabled: false
......@@ -9844,7 +9844,7 @@ A user assigned to a merge request.
| <a id="mergerequestassigneebot"></a>`bot` | [`Boolean!`](#boolean) | Indicates if the user is a bot. |
| <a id="mergerequestassigneecallouts"></a>`callouts` | [`UserCalloutConnection`](#usercalloutconnection) | User callouts that belong to the user. (see [Connections](#connections)) |
| <a id="mergerequestassigneeemail"></a>`email` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.7. This was renamed. Use: [`User.publicEmail`](#userpublicemail). |
| <a id="mergerequestassigneegroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. Available only when feature flag `user_group_counts` is enabled. |
| <a id="mergerequestassigneegroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. |
| <a id="mergerequestassigneegroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) |
| <a id="mergerequestassigneeid"></a>`id` | [`ID!`](#id) | ID of the user. |
| <a id="mergerequestassigneelocation"></a>`location` | [`String`](#string) | The location of the user. |
......@@ -10050,7 +10050,7 @@ A user assigned to a merge request as a reviewer.
| <a id="mergerequestreviewerbot"></a>`bot` | [`Boolean!`](#boolean) | Indicates if the user is a bot. |
| <a id="mergerequestreviewercallouts"></a>`callouts` | [`UserCalloutConnection`](#usercalloutconnection) | User callouts that belong to the user. (see [Connections](#connections)) |
| <a id="mergerequestrevieweremail"></a>`email` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.7. This was renamed. Use: [`User.publicEmail`](#userpublicemail). |
| <a id="mergerequestreviewergroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. Available only when feature flag `user_group_counts` is enabled. |
| <a id="mergerequestreviewergroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. |
| <a id="mergerequestreviewergroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) |
| <a id="mergerequestreviewerid"></a>`id` | [`ID!`](#id) | ID of the user. |
| <a id="mergerequestreviewerlocation"></a>`location` | [`String`](#string) | The location of the user. |
......@@ -12679,7 +12679,7 @@ Core represention of a GitLab user.
| <a id="usercorebot"></a>`bot` | [`Boolean!`](#boolean) | Indicates if the user is a bot. |
| <a id="usercorecallouts"></a>`callouts` | [`UserCalloutConnection`](#usercalloutconnection) | User callouts that belong to the user. (see [Connections](#connections)) |
| <a id="usercoreemail"></a>`email` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.7. This was renamed. Use: [`User.publicEmail`](#userpublicemail). |
| <a id="usercoregroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. Available only when feature flag `user_group_counts` is enabled. |
| <a id="usercoregroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. |
| <a id="usercoregroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) |
| <a id="usercoreid"></a>`id` | [`ID!`](#id) | ID of the user. |
| <a id="usercorelocation"></a>`location` | [`String`](#string) | The location of the user. |
......@@ -15346,7 +15346,7 @@ Implementations:
| <a id="userbot"></a>`bot` | [`Boolean!`](#boolean) | Indicates if the user is a bot. |
| <a id="usercallouts"></a>`callouts` | [`UserCalloutConnection`](#usercalloutconnection) | User callouts that belong to the user. (see [Connections](#connections)) |
| <a id="useremail"></a>`email` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.7. This was renamed. Use: [`User.publicEmail`](#userpublicemail). |
| <a id="usergroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. Available only when feature flag `user_group_counts` is enabled. |
| <a id="usergroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. |
| <a id="usergroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) |
| <a id="userid"></a>`id` | [`ID!`](#id) | ID of the user. |
| <a id="userlocation"></a>`location` | [`String`](#string) | The location of the user. |
......
......@@ -2528,6 +2528,9 @@ msgstr ""
msgid "AdminUsers|Cohorts"
msgstr ""
msgid "AdminUsers|Could not load user group counts. Please refresh the page to try again."
msgstr ""
msgid "AdminUsers|Deactivate"
msgstr ""
......
......@@ -547,6 +547,32 @@ RSpec.describe 'Admin::Users' do
end
end
# TODO: Move to main GET /admin/users block once feature flag is removed. Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/290737
context 'with vue_admin_users feature flag enabled', :js do
before do
stub_feature_flags(vue_admin_users: true)
end
describe 'GET /admin/users' do
context 'user group count', :js do
before do
group = create(:group)
group.add_developer(current_user)
project = create(:project, group: create(:group))
project.add_reporter(current_user)
end
it 'displays count of the users authorized groups' do
visit admin_users_path
wait_for_requests
expect(page.find("[data-testid='user-group-count-#{current_user.id}']").text).to eq("2")
end
end
end
end
def click_user_dropdown_toggle(user_id)
page.within("[data-testid='user-actions-#{user_id}']") do
find("[data-testid='dropdown-toggle']").click
......
import { GlTable } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { GlTable, GlSkeletonLoader } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import AdminUserActions from '~/admin/users/components/user_actions.vue';
import AdminUserAvatar from '~/admin/users/components/user_avatar.vue';
import AdminUsersTable from '~/admin/users/components/users_table.vue';
import getUsersGroupCountsQuery from '~/admin/users/graphql/queries/get_users_group_counts.query.graphql';
import createFlash from '~/flash';
import AdminUserDate from '~/vue_shared/components/user_date.vue';
import { users, paths } from '../mock_data';
import { users, paths, createGroupCountResponse } from '../mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('AdminUsersTable component', () => {
let wrapper;
const user = users[0];
const createFetchGroupCount = (data) =>
jest.fn().mockResolvedValue(createGroupCountResponse(data));
const fetchGroupCountsLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
const fetchGroupCountsError = jest.fn().mockRejectedValue(new Error('Network error'));
const fetchGroupCountsResponse = createFetchGroupCount([{ id: user.id, groupCount: 5 }]);
const findUserGroupCount = (id) => wrapper.findByTestId(`user-group-count-${id}`);
const findUserGroupCountLoader = (id) => findUserGroupCount(id).find(GlSkeletonLoader);
const getCellByLabel = (trIdx, label) => {
return wrapper
.find(GlTable)
......@@ -20,8 +40,16 @@ describe('AdminUsersTable component', () => {
.find(`[data-label="${label}"][role="cell"]`);
};
const initComponent = (props = {}) => {
wrapper = mount(AdminUsersTable, {
function createMockApolloProvider(resolverMock) {
const requestHandlers = [[getUsersGroupCountsQuery, resolverMock]];
return createMockApollo(requestHandlers);
}
const initComponent = (props = {}, resolverMock = fetchGroupCountsResponse) => {
wrapper = mountExtended(AdminUsersTable, {
localVue,
apolloProvider: createMockApolloProvider(resolverMock),
propsData: {
users,
paths,
......@@ -36,8 +64,6 @@ describe('AdminUsersTable component', () => {
});
describe('when there are users', () => {
const user = users[0];
beforeEach(() => {
initComponent();
});
......@@ -69,4 +95,51 @@ describe('AdminUsersTable component', () => {
expect(wrapper.text()).toContain('No users found');
});
});
describe('group counts', () => {
describe('when fetching the data', () => {
beforeEach(() => {
initComponent({}, fetchGroupCountsLoading);
});
it('renders a loader for each user', () => {
expect(findUserGroupCountLoader(user.id).exists()).toBe(true);
});
});
describe('when the data has been fetched', () => {
beforeEach(() => {
initComponent();
});
it("renders the user's group count", () => {
expect(findUserGroupCount(user.id).text()).toBe('5');
});
describe("and a user's group count is null", () => {
beforeEach(() => {
initComponent({}, createFetchGroupCount([{ id: user.id, groupCount: null }]));
});
it("renders the user's group count as 0", () => {
expect(findUserGroupCount(user.id).text()).toBe('0');
});
});
});
describe('when there is an error while fetching the data', () => {
beforeEach(() => {
initComponent({}, fetchGroupCountsError);
});
it('creates a flash message and captures the error', () => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message: 'Could not load user group counts. Please refresh the page to try again.',
captureError: true,
error: expect.any(Error),
});
});
});
});
});
......@@ -10,7 +10,7 @@ export const users = [
'https://secure.gravatar.com/avatar/054f062d8b1a42b123f17e13a173cda8?s=80\\u0026d=identicon',
badges: [
{ text: 'Admin', variant: 'success' },
{ text: "It's you!", variant: null },
{ text: "It's you!", variant: 'muted' },
],
projectsCount: 0,
actions: [],
......@@ -31,3 +31,16 @@ export const paths = {
deleteWithContributions: '/admin/users/id',
adminUser: '/admin/users/id',
};
export const createGroupCountResponse = (groupCounts) => ({
data: {
users: {
nodes: groupCounts.map(({ id, groupCount }) => ({
id: `gid://gitlab/User/${id}`,
groupCount,
__typename: 'UserCore',
})),
__typename: 'UserCoreConnection',
},
},
});
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