Commit a4332ceb authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '349884-created-on-last-activity-members-page' into 'master'

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

See merge request gitlab-org/gitlab!82918
parents edcbb344 484fe31b
......@@ -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 { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
import UserDate from '~/vue_shared/components/user_date.vue';
import {
FIELD_KEY_ACTIONS,
FIELDS,
......@@ -40,6 +41,7 @@ export default {
RemoveGroupLinkModal,
RemoveMemberModal,
ExpirationDatepicker,
UserDate,
LdapOverrideConfirmationModal: () =>
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
......@@ -287,6 +289,14 @@ export default {
</members-table-cell>
</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 }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons
......
......@@ -9,6 +9,8 @@ export const FIELD_KEY_GRANTED = 'granted';
export const FIELD_KEY_INVITED = 'invited';
export const FIELD_KEY_REQUESTED = 'requested';
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_LAST_SIGN_IN = 'lastSignIn';
export const FIELD_KEY_ACTIONS = 'actions';
......@@ -66,6 +68,22 @@ export const FIELDS = [
thClass: '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,
label: __('Last sign-in'),
......
......@@ -12,9 +12,16 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-group-members-list-app'), {
[MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
tableSortableFields: [
'account',
'granted',
'maxRole',
'lastSignIn',
'userCreatedAt',
'lastActivityOn',
],
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
show: true,
......
......@@ -18,9 +18,16 @@ initInviteGroupTrigger();
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
tableSortableFields: [
'account',
'granted',
'maxRole',
'lastSignIn',
'userCreatedAt',
'lastActivityOn',
],
requestFormatter: projectMemberRequestFormatter,
filteredSearchBar: {
show: true,
......
......@@ -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_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_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) }
......@@ -232,6 +236,10 @@ class Member < ApplicationRecord
when 'access_level_desc' then reorder(access_level: :desc)
when 'recent_sign_in' then order_recent_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 'oldest_joined' then order_created_asc
else
......
......@@ -5,6 +5,9 @@ class MemberUserEntity < UserEntity
unexpose :state
unexpose :status_tooltip_html
expose :created_at
expose :last_activity_on
expose :avatar_url do |user|
user.avatar_url(size: Member::AVATAR_SIZE, only_path: false)
end
......
......@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'Groups > Members > Sort members', :js do
include Spec::Support::Helpers::Features::MembersHelpers
let(:owner) { create(:user, name: 'John Doe') }
let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
let(:owner) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
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) }
before do
......@@ -50,6 +50,42 @@ RSpec.describe 'Groups > Members > Sort members', :js do
expect_sort_by('Max role', :desc)
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
visit_members_list(sort: :last_joined)
......
......@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'Projects > Members > Sorting', :js do
include Spec::Support::Helpers::Features::MembersHelpers
let(:maintainer) { create(:user, name: 'John Doe') }
let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
let(:maintainer) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
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) }
before do
......@@ -42,6 +42,42 @@ RSpec.describe 'Projects > Members > Sorting', :js do
expect_sort_by('Max role', :desc)
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
visit_members_list(sort: :last_joined)
......
{
"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": {
"id": { "type": "integer" },
"name": { "type": "string" },
"username": { "type": "string" },
"created_at": { "type": ["string"] },
"avatar_url": { "type": ["string", "null"] },
"web_url": { "type": "string" },
"blocked": { "type": "boolean" },
"two_factor_enabled": { "type": "boolean" },
"availability": { "type": ["string", "null"] },
"last_activity_on": { "type": ["string", "null"] },
"status": {
"type": "object",
"required": ["emoji"],
......
......@@ -10,6 +10,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 UserDate from '~/vue_shared/components/user_date.vue';
import {
MEMBER_TYPES,
MEMBER_STATE_CREATED,
......@@ -106,14 +107,16 @@ describe('MembersTable', () => {
};
it.each`
field | label | member | expectedComponent
${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
field | label | member | expectedComponent
${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
${'userCreatedAt'} | ${'Created on'} | ${memberMock} | ${UserDate}
${'lastActivityOn'} | ${'Last activity'} | ${memberMock} | ${UserDate}
`('renders the $label field', ({ field, label, member, expectedComponent }) => {
createComponent({
members: [member],
......
......@@ -17,6 +17,7 @@ export const member = {
state: MEMBER_STATE_CREATED,
user: {
id: 123,
createdAt: '2022-03-10T18:03:04.812Z',
name: 'Administrator',
username: 'root',
webUrl: 'https://gitlab.com/root',
......@@ -26,6 +27,7 @@ export const member = {
oncallSchedules: [{ name: 'schedule 1' }],
escalationPolicies: [{ name: 'policy 1' }],
availability: null,
lastActivityOn: '2022-03-15',
showStatus: true,
},
id: 238,
......
......@@ -926,4 +926,64 @@ RSpec.describe Member do
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
......@@ -3,7 +3,7 @@
require 'spec_helper'
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(:user_status) { create(:user_status, user: user, emoji: emoji) }
......@@ -36,4 +36,12 @@ RSpec.describe MemberUserEntity do
it 'correctly exposes `status.emoji`' do
expect(entity_hash[:status][:emoji]).to match(emoji)
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
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