Commit 484fe31b authored by Abdul Wadood's avatar Abdul Wadood Committed by Ezekiel Kigbo

Add "Created on" & "Last activity" columns to groups and projects page

The columns are sortable.
We want to achieve parity between self and SaaS user. Until now this
info was only present in the admin panel.

Changelog: added
parent 7680b4b9
...@@ -5,6 +5,7 @@ import MembersTableCell from 'ee_else_ce/members/components/table/members_table_ ...@@ -5,6 +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 { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers'; import initUserPopovers from '~/user_popovers';
import UserDate from '~/vue_shared/components/user_date.vue';
import { import {
FIELD_KEY_ACTIONS, FIELD_KEY_ACTIONS,
FIELDS, FIELDS,
...@@ -40,6 +41,7 @@ export default { ...@@ -40,6 +41,7 @@ export default {
RemoveGroupLinkModal, RemoveGroupLinkModal,
RemoveMemberModal, RemoveMemberModal,
ExpirationDatepicker, ExpirationDatepicker,
UserDate,
LdapOverrideConfirmationModal: () => LdapOverrideConfirmationModal: () =>
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
}, },
...@@ -287,6 +289,14 @@ export default { ...@@ -287,6 +289,14 @@ export default {
</members-table-cell> </members-table-cell>
</template> </template>
<template #cell(userCreatedAt)="{ item: member }">
<user-date :date="member.user.createdAt" />
</template>
<template #cell(lastActivityOn)="{ item: member }">
<user-date :date="member.user.lastActivityOn" />
</template>
<template #cell(actions)="{ item: member }"> <template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons <member-action-buttons
......
...@@ -9,6 +9,8 @@ export const FIELD_KEY_GRANTED = 'granted'; ...@@ -9,6 +9,8 @@ export const FIELD_KEY_GRANTED = 'granted';
export const FIELD_KEY_INVITED = 'invited'; export const FIELD_KEY_INVITED = 'invited';
export const FIELD_KEY_REQUESTED = 'requested'; export const FIELD_KEY_REQUESTED = 'requested';
export const FIELD_KEY_MAX_ROLE = 'maxRole'; export const FIELD_KEY_MAX_ROLE = 'maxRole';
export const FIELD_KEY_USER_CREATED_AT = 'userCreatedAt';
export const FIELD_KEY_LAST_ACTIVITY_ON = 'lastActivityOn';
export const FIELD_KEY_EXPIRATION = 'expiration'; export const FIELD_KEY_EXPIRATION = 'expiration';
export const FIELD_KEY_LAST_SIGN_IN = 'lastSignIn'; export const FIELD_KEY_LAST_SIGN_IN = 'lastSignIn';
export const FIELD_KEY_ACTIONS = 'actions'; export const FIELD_KEY_ACTIONS = 'actions';
...@@ -66,6 +68,22 @@ export const FIELDS = [ ...@@ -66,6 +68,22 @@ export const FIELDS = [
thClass: 'col-expiration', thClass: 'col-expiration',
tdClass: 'col-expiration', tdClass: 'col-expiration',
}, },
{
key: FIELD_KEY_USER_CREATED_AT,
label: __('Created on'),
sort: {
asc: 'oldest_created_user',
desc: 'recent_created_user',
},
},
{
key: FIELD_KEY_LAST_ACTIVITY_ON,
label: __('Last activity'),
sort: {
asc: 'oldest_last_activity',
desc: 'recent_last_activity',
},
},
{ {
key: FIELD_KEY_LAST_SIGN_IN, key: FIELD_KEY_LAST_SIGN_IN,
label: __('Last sign-in'), label: __('Last sign-in'),
......
...@@ -12,9 +12,16 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; ...@@ -12,9 +12,16 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-group-members-list-app'), { initMembersApp(document.querySelector('.js-group-members-list-app'), {
[MEMBER_TYPES.user]: { [MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted']), tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], tableSortableFields: [
'account',
'granted',
'maxRole',
'lastSignIn',
'userCreatedAt',
'lastActivityOn',
],
requestFormatter: groupMemberRequestFormatter, requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: { filteredSearchBar: {
show: true, show: true,
......
...@@ -18,9 +18,16 @@ initInviteGroupTrigger(); ...@@ -18,9 +18,16 @@ initInviteGroupTrigger();
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), { initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: { [MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted']), tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], tableSortableFields: [
'account',
'granted',
'maxRole',
'lastSignIn',
'userCreatedAt',
'lastActivityOn',
],
requestFormatter: projectMemberRequestFormatter, requestFormatter: projectMemberRequestFormatter,
filteredSearchBar: { filteredSearchBar: {
show: true, show: true,
......
...@@ -181,6 +181,10 @@ class Member < ApplicationRecord ...@@ -181,6 +181,10 @@ class Member < ApplicationRecord
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) } scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) } scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) } scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) }
scope :order_recent_last_activity, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_activity_on', 'DESC')) }
scope :order_oldest_last_activity, -> { left_join_users.reorder(Gitlab::Database.nulls_first_order('users.last_activity_on', 'ASC')) }
scope :order_recent_created_user, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.created_at', 'DESC')) }
scope :order_oldest_created_user, -> { left_join_users.reorder(Gitlab::Database.nulls_first_order('users.created_at', 'ASC')) }
scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) } scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) }
...@@ -232,6 +236,10 @@ class Member < ApplicationRecord ...@@ -232,6 +236,10 @@ class Member < ApplicationRecord
when 'access_level_desc' then reorder(access_level: :desc) when 'access_level_desc' then reorder(access_level: :desc)
when 'recent_sign_in' then order_recent_sign_in when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in when 'oldest_sign_in' then order_oldest_sign_in
when 'recent_created_user' then order_recent_created_user
when 'oldest_created_user' then order_oldest_created_user
when 'recent_last_activity' then order_recent_last_activity
when 'oldest_last_activity' then order_oldest_last_activity
when 'last_joined' then order_created_desc when 'last_joined' then order_created_desc
when 'oldest_joined' then order_created_asc when 'oldest_joined' then order_created_asc
else else
......
...@@ -5,6 +5,9 @@ class MemberUserEntity < UserEntity ...@@ -5,6 +5,9 @@ class MemberUserEntity < UserEntity
unexpose :state unexpose :state
unexpose :status_tooltip_html unexpose :status_tooltip_html
expose :created_at
expose :last_activity_on
expose :avatar_url do |user| expose :avatar_url do |user|
user.avatar_url(size: Member::AVATAR_SIZE, only_path: false) user.avatar_url(size: Member::AVATAR_SIZE, only_path: false)
end end
......
...@@ -5,8 +5,8 @@ require 'spec_helper' ...@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'Groups > Members > Sort members', :js do RSpec.describe 'Groups > Members > Sort members', :js do
include Spec::Support::Helpers::Features::MembersHelpers include Spec::Support::Helpers::Features::MembersHelpers
let(:owner) { create(:user, name: 'John Doe') } let(:owner) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) } let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) }
let(:group) { create(:group) } let(:group) { create(:group) }
before do before do
...@@ -50,6 +50,42 @@ RSpec.describe 'Groups > Members > Sort members', :js do ...@@ -50,6 +50,42 @@ RSpec.describe 'Groups > Members > Sort members', :js do
expect_sort_by('Max role', :desc) expect_sort_by('Max role', :desc)
end end
it 'sorts by user created on ascending' do
visit_members_list(sort: :oldest_created_user)
expect(first_row.text).to include(owner.name)
expect(second_row.text).to include(developer.name)
expect_sort_by('Created on', :asc)
end
it 'sorts by user created on descending' do
visit_members_list(sort: :recent_created_user)
expect(first_row.text).to include(developer.name)
expect(second_row.text).to include(owner.name)
expect_sort_by('Created on', :desc)
end
it 'sorts by last activity ascending' do
visit_members_list(sort: :oldest_last_activity)
expect(first_row.text).to include(developer.name)
expect(second_row.text).to include(owner.name)
expect_sort_by('Last activity', :asc)
end
it 'sorts by last activity descending' do
visit_members_list(sort: :recent_last_activity)
expect(first_row.text).to include(owner.name)
expect(second_row.text).to include(developer.name)
expect_sort_by('Last activity', :desc)
end
it 'sorts by access granted ascending' do it 'sorts by access granted ascending' do
visit_members_list(sort: :last_joined) visit_members_list(sort: :last_joined)
......
...@@ -5,8 +5,8 @@ require 'spec_helper' ...@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'Projects > Members > Sorting', :js do RSpec.describe 'Projects > Members > Sorting', :js do
include Spec::Support::Helpers::Features::MembersHelpers include Spec::Support::Helpers::Features::MembersHelpers
let(:maintainer) { create(:user, name: 'John Doe') } let(:maintainer) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) } let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) }
let(:project) { create(:project, namespace: maintainer.namespace, creator: maintainer) } let(:project) { create(:project, namespace: maintainer.namespace, creator: maintainer) }
before do before do
...@@ -42,6 +42,42 @@ RSpec.describe 'Projects > Members > Sorting', :js do ...@@ -42,6 +42,42 @@ RSpec.describe 'Projects > Members > Sorting', :js do
expect_sort_by('Max role', :desc) expect_sort_by('Max role', :desc)
end end
it 'sorts by user created on ascending' do
visit_members_list(sort: :oldest_created_user)
expect(first_row.text).to have_content(maintainer.name)
expect(second_row.text).to have_content(developer.name)
expect_sort_by('Created on', :asc)
end
it 'sorts by user created on descending' do
visit_members_list(sort: :recent_created_user)
expect(first_row.text).to have_content(developer.name)
expect(second_row.text).to have_content(maintainer.name)
expect_sort_by('Created on', :desc)
end
it 'sorts by last activity ascending' do
visit_members_list(sort: :oldest_last_activity)
expect(first_row.text).to have_content(developer.name)
expect(second_row.text).to have_content(maintainer.name)
expect_sort_by('Last activity', :asc)
end
it 'sorts by last activity descending' do
visit_members_list(sort: :recent_last_activity)
expect(first_row.text).to have_content(maintainer.name)
expect(second_row.text).to have_content(developer.name)
expect_sort_by('Last activity', :desc)
end
it 'sorts by access granted ascending' do it 'sorts by access granted ascending' do
visit_members_list(sort: :last_joined) visit_members_list(sort: :last_joined)
......
{ {
"type": "object", "type": "object",
"required": ["id", "name", "username", "avatar_url", "web_url", "blocked", "two_factor_enabled", "show_status"], "required": [
"id",
"name",
"username",
"created_at",
"last_activity_on",
"avatar_url",
"web_url",
"blocked",
"two_factor_enabled",
"show_status"
],
"properties": { "properties": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"name": { "type": "string" }, "name": { "type": "string" },
"username": { "type": "string" }, "username": { "type": "string" },
"created_at": { "type": ["string"] },
"avatar_url": { "type": ["string", "null"] }, "avatar_url": { "type": ["string", "null"] },
"web_url": { "type": "string" }, "web_url": { "type": "string" },
"blocked": { "type": "boolean" }, "blocked": { "type": "boolean" },
"two_factor_enabled": { "type": "boolean" }, "two_factor_enabled": { "type": "boolean" },
"availability": { "type": ["string", "null"] }, "availability": { "type": ["string", "null"] },
"last_activity_on": { "type": ["string", "null"] },
"status": { "status": {
"type": "object", "type": "object",
"required": ["emoji"], "required": ["emoji"],
......
...@@ -10,6 +10,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue'; ...@@ -10,6 +10,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue';
import MemberSource from '~/members/components/table/member_source.vue'; import MemberSource from '~/members/components/table/member_source.vue';
import MembersTable from '~/members/components/table/members_table.vue'; import MembersTable from '~/members/components/table/members_table.vue';
import RoleDropdown from '~/members/components/table/role_dropdown.vue'; import RoleDropdown from '~/members/components/table/role_dropdown.vue';
import UserDate from '~/vue_shared/components/user_date.vue';
import { import {
MEMBER_TYPES, MEMBER_TYPES,
MEMBER_STATE_CREATED, MEMBER_STATE_CREATED,
...@@ -106,14 +107,16 @@ describe('MembersTable', () => { ...@@ -106,14 +107,16 @@ describe('MembersTable', () => {
}; };
it.each` it.each`
field | label | member | expectedComponent field | label | member | expectedComponent
${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
${'userCreatedAt'} | ${'Created on'} | ${memberMock} | ${UserDate}
${'lastActivityOn'} | ${'Last activity'} | ${memberMock} | ${UserDate}
`('renders the $label field', ({ field, label, member, expectedComponent }) => { `('renders the $label field', ({ field, label, member, expectedComponent }) => {
createComponent({ createComponent({
members: [member], members: [member],
......
...@@ -17,6 +17,7 @@ export const member = { ...@@ -17,6 +17,7 @@ export const member = {
state: MEMBER_STATE_CREATED, state: MEMBER_STATE_CREATED,
user: { user: {
id: 123, id: 123,
createdAt: '2022-03-10T18:03:04.812Z',
name: 'Administrator', name: 'Administrator',
username: 'root', username: 'root',
webUrl: 'https://gitlab.com/root', webUrl: 'https://gitlab.com/root',
...@@ -26,6 +27,7 @@ export const member = { ...@@ -26,6 +27,7 @@ export const member = {
oncallSchedules: [{ name: 'schedule 1' }], oncallSchedules: [{ name: 'schedule 1' }],
escalationPolicies: [{ name: 'policy 1' }], escalationPolicies: [{ name: 'policy 1' }],
availability: null, availability: null,
lastActivityOn: '2022-03-15',
showStatus: true, showStatus: true,
}, },
id: 238, id: 238,
......
...@@ -926,4 +926,64 @@ RSpec.describe Member do ...@@ -926,4 +926,64 @@ RSpec.describe Member do
end end
end end
end end
describe '.sort_by_attribute' do
let_it_be(:user1) { create(:user, created_at: Date.today, last_sign_in_at: Date.today, last_activity_on: Date.today, name: 'Alpha') }
let_it_be(:user2) { create(:user, created_at: Date.today - 1, last_sign_in_at: Date.today - 1, last_activity_on: Date.today - 1, name: 'Omega') }
let_it_be(:user3) { create(:user, created_at: Date.today - 2, name: 'Beta') }
let_it_be(:group) { create(:group) }
let_it_be(:member1) { create(:group_member, :reporter, group: group, user: user1) }
let_it_be(:member2) { create(:group_member, :developer, group: group, user: user2) }
let_it_be(:member3) { create(:group_member, :maintainer, group: group, user: user3) }
it 'sort users in ascending order by access-level' do
expect(described_class.sort_by_attribute('access_level_asc')).to eq([member1, member2, member3])
end
it 'sort users in descending order by access-level' do
expect(described_class.sort_by_attribute('access_level_desc')).to eq([member3, member2, member1])
end
context 'when sort by recent_sign_in' do
subject { described_class.sort_by_attribute('recent_sign_in') }
it 'sorts users by recent sign-in time' do
expect(subject.first).to eq(member1)
expect(subject.second).to eq(member2)
end
it 'pushes users who never signed in to the end' do
expect(subject.third).to eq(member3)
end
end
context 'when sort by oldest_sign_in' do
subject { described_class.sort_by_attribute('oldest_sign_in') }
it 'sorts users by the oldest sign-in time' do
expect(subject.first).to eq(member2)
expect(subject.second).to eq(member1)
end
it 'pushes users who never signed in to the end' do
expect(subject.third).to eq(member3)
end
end
it 'sorts users in descending order by their creation time' do
expect(described_class.sort_by_attribute('recent_created_user')).to eq([member1, member2, member3])
end
it 'sorts users in ascending order by their creation time' do
expect(described_class.sort_by_attribute('oldest_created_user')).to eq([member3, member2, member1])
end
it 'sort users by recent last activity' do
expect(described_class.sort_by_attribute('recent_last_activity')).to eq([member1, member2, member3])
end
it 'sort users by oldest last activity' do
expect(described_class.sort_by_attribute('oldest_last_activity')).to eq([member3, member2, member1])
end
end
end end
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe MemberUserEntity do RSpec.describe MemberUserEntity do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user, last_activity_on: Date.today) }
let_it_be(:emoji) { 'slight_smile' } let_it_be(:emoji) { 'slight_smile' }
let_it_be(:user_status) { create(:user_status, user: user, emoji: emoji) } let_it_be(:user_status) { create(:user_status, user: user, emoji: emoji) }
...@@ -36,4 +36,12 @@ RSpec.describe MemberUserEntity do ...@@ -36,4 +36,12 @@ RSpec.describe MemberUserEntity do
it 'correctly exposes `status.emoji`' do it 'correctly exposes `status.emoji`' do
expect(entity_hash[:status][:emoji]).to match(emoji) expect(entity_hash[:status][:emoji]).to match(emoji)
end end
it 'correctly exposes `created_at`' do
expect(entity_hash[:created_at]).to be(user.created_at)
end
it 'correctly exposes `last_activity_on`' do
expect(entity_hash[:last_activity_on]).to be(user.last_activity_on)
end
end end
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