mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-26 21:27:40 -05:00
Add single-channel guests filter and channel count column to System Console Users (#35517)
* Add single-channel guests filter and channel count column to System Console Users - Add guest_filter query parameter to Reports API with store-level filtering by guest channel membership count (all, single_channel, multi_channel) - Add channel_count field to user report responses and CSV exports - Add grouped guest role filter options in the filter popover - Add toggleable Channel count column to the users table - Add GuestFilter and SearchTerm to Go client GetUsersForReporting - Add tests: API parsing, API integration, app job dedup, webapp utils, E2E column data rendering Made-with: Cursor * Fix gofmt alignment and isolate guest store tests - Align GuestFilter constants to satisfy gofmt - Move guest user/channel setup into a nested sub-test to avoid breaking existing ordering and role filter assertions Made-with: Cursor * Exclude archived channels from guest filter queries and ChannelCount The ChannelMembers subqueries for guest_filter (single/multi channel) and the ChannelCount column did not join with Channels to check DeleteAt = 0. Since channel archival soft-deletes (sets DeleteAt) but leaves ChannelMembers rows intact, archived channel memberships were incorrectly counted, potentially misclassifying guests between single-channel and multi-channel filters and inflating ChannelCount. - Join ChannelMembers with Channels (DeleteAt = 0) in all three subqueries in applyUserReportFilter and GetUserReport - Add store test covering archived channel exclusion - Tighten existing guest filter test assertions with found-flags and exact count checks Made-with: Cursor * Exclude DM/GM from guest channel counts, validate GuestFilter, fix dropdown divider - Scope ChannelCount and guest filter subqueries to Open/Private channel types only (exclude DM and GM), so a guest with one team channel plus a DM is correctly classified as single-channel - Add GuestFilter validation in UserReportOptions.IsValid with AllowedGuestFilters whitelist - Add API test for invalid guest_filter rejection (400) - Add store regression test for DM/GM exclusion - Fix role filter dropdown: hide the divider above the first group heading via CSS rule on DropDown__group:first-child - Update E2E test label to match "Guests in a single channel" wording Made-with: Cursor * Add store test coverage for private and GM channel types Private channels (type P) should be counted in ChannelCount and guest filters, while GM channels (type G) should not. Add a test that creates a guest with memberships in an open channel, a private channel, and a GM, then asserts ChannelCount = 2, multi-channel filter includes the guest, and single-channel filter excludes them. Made-with: Cursor * Add server i18n translation for invalid_guest_filter error The new error ID model.user_report_options.is_valid.invalid_guest_filter was missing from server/i18n/en.json, causing CI to fail. Made-with: Cursor * Make filter dropdown dividers full width Remove the horizontal inset from grouped dropdown separators so the system user role filter dividers span edge to edge across the menu. Leave the unrelated webapp/package-lock.json change uncommitted. Made-with: Cursor * Optimize guest channel report filters. Use per-user channel count subqueries for the single- and multi-channel guest filters so the report avoids aggregating all channel memberships before filtering guests.
This commit is contained in:
+7
-1
@@ -50,7 +50,13 @@ export class ColumnToggleMenu {
|
||||
}
|
||||
}
|
||||
|
||||
type RoleFilter = 'Any' | 'System Admin' | 'Member' | 'Guest';
|
||||
type RoleFilter =
|
||||
| 'Any'
|
||||
| 'System Admin'
|
||||
| 'Member'
|
||||
| 'Guests (all)'
|
||||
| 'Guests in a single channel'
|
||||
| 'Guests in multiple channels';
|
||||
type StatusFilter = 'Any' | 'Activated users' | 'Deactivated users';
|
||||
|
||||
/**
|
||||
|
||||
+5
@@ -22,6 +22,7 @@ export class UsersTable {
|
||||
readonly lastPostHeader: Locator;
|
||||
readonly daysActiveHeader: Locator;
|
||||
readonly messagesPostedHeader: Locator;
|
||||
readonly channelCountHeader: Locator;
|
||||
readonly actionsHeader: Locator;
|
||||
|
||||
constructor(container: Locator) {
|
||||
@@ -38,6 +39,7 @@ export class UsersTable {
|
||||
this.lastPostHeader = container.locator('#systemUsersTable-header-lastPostDateColumn');
|
||||
this.daysActiveHeader = container.locator('#systemUsersTable-header-daysActiveColumn');
|
||||
this.messagesPostedHeader = container.locator('#systemUsersTable-header-totalPostsColumn');
|
||||
this.channelCountHeader = container.locator('#systemUsersTable-header-channelCountColumn');
|
||||
this.actionsHeader = container.locator('#systemUsersTable-header-actionsColumn');
|
||||
}
|
||||
|
||||
@@ -65,6 +67,7 @@ export class UsersTable {
|
||||
'Last post': this.lastPostHeader,
|
||||
'Days active': this.daysActiveHeader,
|
||||
'Messages posted': this.messagesPostedHeader,
|
||||
'Channel count': this.channelCountHeader,
|
||||
Actions: this.actionsHeader,
|
||||
};
|
||||
const header = headerMap[columnName];
|
||||
@@ -143,6 +146,7 @@ export class UserRow {
|
||||
readonly lastPostCell: Locator;
|
||||
readonly daysActiveCell: Locator;
|
||||
readonly messagesPostedCell: Locator;
|
||||
readonly channelCountCell: Locator;
|
||||
readonly actionsCell: Locator;
|
||||
|
||||
// User details components
|
||||
@@ -168,6 +172,7 @@ export class UserRow {
|
||||
this.lastPostCell = container.locator('.lastPostDateColumn');
|
||||
this.daysActiveCell = container.locator('.daysActiveColumn');
|
||||
this.messagesPostedCell = container.locator('.totalPostsColumn');
|
||||
this.channelCountCell = container.locator('.channelCountColumn');
|
||||
this.actionsCell = container.locator('.actionsColumn');
|
||||
|
||||
this.profilePicture = this.userDetailsCell.locator('.profilePicture');
|
||||
|
||||
+114
-1
@@ -29,7 +29,7 @@ test('MM-T5523-3 Should list the column names with checkboxes in the correct ord
|
||||
const menuItemsTexts = await menuItems.allInnerTexts();
|
||||
|
||||
// * Verify menu items exists in the correct order
|
||||
expect(menuItemsTexts).toHaveLength(9);
|
||||
expect(menuItemsTexts).toHaveLength(10);
|
||||
expect(menuItemsTexts).toEqual([
|
||||
'User details',
|
||||
'Email',
|
||||
@@ -39,6 +39,7 @@ test('MM-T5523-3 Should list the column names with checkboxes in the correct ord
|
||||
'Last post',
|
||||
'Days active',
|
||||
'Messages posted',
|
||||
'Channel count',
|
||||
'Actions',
|
||||
]);
|
||||
});
|
||||
@@ -124,3 +125,115 @@ test('MM-T5523-5 Should show/hide the columns which are toggled on/off', async (
|
||||
// * Verify that however Last login column is still hidden as we did not check it on
|
||||
await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Last login'})).not.toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that the Channel count column displays a numeric value for a user with known channel memberships
|
||||
*
|
||||
* @precondition
|
||||
* A guest user exists with exactly two channel memberships
|
||||
*/
|
||||
test(
|
||||
'displays numeric channel count value when Channel count column is enabled',
|
||||
{tag: '@system_users'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Create two channels
|
||||
const ch1Name = `count-ch1-${await pw.random.id()}`;
|
||||
const channel1 = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: ch1Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
display_name: ch1Name,
|
||||
type: 'O',
|
||||
});
|
||||
|
||||
const ch2Name = `count-ch2-${await pw.random.id()}`;
|
||||
const channel2 = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: ch2Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
display_name: ch2Name,
|
||||
type: 'O',
|
||||
});
|
||||
|
||||
// # Create a guest user and add to exactly two channels
|
||||
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guestUser.id);
|
||||
await adminClient.addToChannel(guestUser.id, channel1.id);
|
||||
await adminClient.addToChannel(guestUser.id, channel2.id);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Users section
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
|
||||
// # Enable Channel count column
|
||||
const columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
|
||||
await columnToggleMenu.clickMenuItem('Channel count');
|
||||
await columnToggleMenu.close();
|
||||
|
||||
// * Verify Channel count column header is visible
|
||||
await expect(
|
||||
systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'}),
|
||||
).toBeVisible();
|
||||
|
||||
// # Search for the guest user
|
||||
await systemConsolePage.users.searchUsers(guestUser.email);
|
||||
await systemConsolePage.users.isLoadingComplete();
|
||||
|
||||
// * Verify the Channel count cell displays the expected numeric value
|
||||
const firstRow = systemConsolePage.users.container.locator('tbody tr').first();
|
||||
const channelCountCell = firstRow.locator('.channelCountColumn');
|
||||
await expect(channelCountCell).toHaveText('2');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that the Channel count column can be toggled on and off
|
||||
*/
|
||||
test('toggles Channel count column visibility on and off', {tag: '@system_users'}, async ({pw}) => {
|
||||
const {adminUser} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Users section
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
|
||||
// # Open the column toggle menu and enable Channel count
|
||||
let columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
|
||||
await columnToggleMenu.clickMenuItem('Channel count');
|
||||
await columnToggleMenu.close();
|
||||
|
||||
// * Verify Channel count column header is visible
|
||||
await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'})).toBeVisible();
|
||||
|
||||
// # Open column toggle menu again and disable Channel count
|
||||
columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
|
||||
await columnToggleMenu.clickMenuItem('Channel count');
|
||||
await columnToggleMenu.close();
|
||||
|
||||
// * Verify Channel count column header is hidden
|
||||
await expect(
|
||||
systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'}),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
+175
-1
@@ -78,7 +78,7 @@ test('MM-T5521-8 Should be able to filter users with role filter', async ({pw})
|
||||
const filterPopover = await systemConsolePage.users.openFilterPopover();
|
||||
|
||||
// # Open the role filter in the popover and select Guest
|
||||
await filterPopover.filterByRole('Guest');
|
||||
await filterPopover.filterByRole('Guests (all)');
|
||||
|
||||
// # Save the filter and close the popover
|
||||
await filterPopover.save();
|
||||
@@ -145,3 +145,177 @@ test('MM-T5521-9 Should be able to filter users with status filter', async ({pw}
|
||||
// * Verify that regular user is not visible as 'Deactivated' status filter was applied
|
||||
await expect(systemConsolePage.users.container.getByText('No data')).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that the role filter dropdown shows all guest filter variants
|
||||
*/
|
||||
test('displays all guest filter variants in the role filter dropdown', {tag: '@system_users'}, async ({pw}) => {
|
||||
const {adminUser} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Users section
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
|
||||
// # Open the filter popover
|
||||
const filterPopover = await systemConsolePage.users.openFilterPopover();
|
||||
|
||||
// # Open the role filter menu
|
||||
await filterPopover.openRoleMenu();
|
||||
|
||||
// * Verify all 6 role filter options are present
|
||||
const roleOptions = filterPopover.container.getByRole('option');
|
||||
const roleTexts = await roleOptions.allInnerTexts();
|
||||
|
||||
expect(roleTexts).toEqual([
|
||||
'Any',
|
||||
'System Admin',
|
||||
'Member',
|
||||
'Guests (all)',
|
||||
'Guests in a single channel',
|
||||
'Guests in multiple channels',
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that filtering by single-channel guest filter returns only guests with exactly one channel membership
|
||||
*
|
||||
* @precondition
|
||||
* A guest user exists with exactly one channel membership
|
||||
*/
|
||||
test(
|
||||
'filters users by single-channel guest filter and shows only single-channel guests',
|
||||
{tag: '@system_users'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Create a channel
|
||||
const channelName = `guest-ch-${await pw.random.id()}`;
|
||||
const channel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
display_name: channelName,
|
||||
type: 'O',
|
||||
});
|
||||
|
||||
// # Create a guest user and add to exactly one channel
|
||||
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, guestUser.id);
|
||||
await adminClient.addToChannel(guestUser.id, channel.id);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Users section
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
|
||||
// # Open the filter popover and filter by single-channel guests
|
||||
const filterPopover = await systemConsolePage.users.openFilterPopover();
|
||||
await filterPopover.filterByRole('Guests in a single channel');
|
||||
await filterPopover.save();
|
||||
await systemConsolePage.users.isLoadingComplete();
|
||||
|
||||
// # Search for the guest user
|
||||
await systemConsolePage.users.searchUsers(guestUser.email);
|
||||
|
||||
// * Verify the single-channel guest is visible
|
||||
await expect(systemConsolePage.users.container.getByText(guestUser.email)).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that filtering by multi-channel guest filter returns only guests with more than one channel membership
|
||||
*
|
||||
* @precondition
|
||||
* A guest user exists with two channel memberships and another with one
|
||||
*/
|
||||
test(
|
||||
'filters users by multi-channel guest filter and excludes single-channel guests',
|
||||
{tag: '@system_users'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
// # Create two channels
|
||||
const ch1Name = `guest-multi-1-${await pw.random.id()}`;
|
||||
const channel1 = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: ch1Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
display_name: ch1Name,
|
||||
type: 'O',
|
||||
});
|
||||
|
||||
const ch2Name = `guest-multi-2-${await pw.random.id()}`;
|
||||
const channel2 = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: ch2Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
display_name: ch2Name,
|
||||
type: 'O',
|
||||
});
|
||||
|
||||
// # Create a guest user with 2 channel memberships
|
||||
const multiChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(multiChannelGuest.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, multiChannelGuest.id);
|
||||
await adminClient.addToChannel(multiChannelGuest.id, channel1.id);
|
||||
await adminClient.addToChannel(multiChannelGuest.id, channel2.id);
|
||||
|
||||
// # Create a guest user with only 1 channel membership
|
||||
const singleChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
|
||||
await adminClient.updateUserRoles(singleChannelGuest.id, 'system_guest');
|
||||
await adminClient.addToTeam(team.id, singleChannelGuest.id);
|
||||
await adminClient.addToChannel(singleChannelGuest.id, channel1.id);
|
||||
|
||||
// # Log in as admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
// # Visit system console
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
// # Go to Users section
|
||||
await systemConsolePage.sidebar.users.click();
|
||||
await systemConsolePage.users.toBeVisible();
|
||||
|
||||
// # Open the filter popover and filter by multi-channel guests
|
||||
const filterPopover = await systemConsolePage.users.openFilterPopover();
|
||||
await filterPopover.filterByRole('Guests in multiple channels');
|
||||
await filterPopover.save();
|
||||
await systemConsolePage.users.isLoadingComplete();
|
||||
|
||||
// # Search for the multi-channel guest
|
||||
await systemConsolePage.users.searchUsers(multiChannelGuest.email);
|
||||
|
||||
// * Verify the multi-channel guest is visible
|
||||
await expect(systemConsolePage.users.container.getByText(multiChannelGuest.email)).toBeVisible();
|
||||
|
||||
// # Search for the single-channel guest
|
||||
await systemConsolePage.users.searchUsers(singleChannelGuest.email);
|
||||
|
||||
// * Verify the single-channel guest is NOT visible
|
||||
await expect(systemConsolePage.users.container.getByText('No data')).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -154,6 +154,7 @@ func fillUserReportOptions(values url.Values) (*model.UserReportOptions, *model.
|
||||
HideActive: hideActive,
|
||||
HideInactive: hideInactive,
|
||||
SearchTerm: values.Get("search_term"),
|
||||
GuestFilter: values.Get("guest_filter"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,69 @@ func TestGetUsersForReporting(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("should filter by guest_filter single_channel and return channel_count", func(t *testing.T) {
|
||||
th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
|
||||
// Create a guest user with exactly one channel membership
|
||||
singleChannelGuest := th.CreateUser(t)
|
||||
_, appErr := th.App.UpdateUserRoles(th.Context, singleChannelGuest.Id, model.SystemGuestRoleId, false)
|
||||
require.Nil(t, appErr)
|
||||
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, singleChannelGuest.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, singleChannelGuest, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Create a guest user with two channel memberships
|
||||
multiChannelGuest := th.CreateUser(t)
|
||||
_, appErr = th.App.UpdateUserRoles(th.Context, multiChannelGuest.Id, model.SystemGuestRoleId, false)
|
||||
require.Nil(t, appErr)
|
||||
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, multiChannelGuest.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, multiChannelGuest, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, multiChannelGuest, th.BasicChannel2, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
options := &model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
PageSize: 100,
|
||||
},
|
||||
GuestFilter: model.GuestFilterSingleChannel,
|
||||
}
|
||||
|
||||
userReports, resp, err := client.GetUsersForReporting(context.Background(), options)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
foundSingle := false
|
||||
for _, report := range userReports {
|
||||
require.Contains(t, report.Roles, "system_guest")
|
||||
require.NotNil(t, report.ChannelCount)
|
||||
|
||||
if report.Id == singleChannelGuest.Id {
|
||||
foundSingle = true
|
||||
require.Equal(t, 1, *report.ChannelCount)
|
||||
}
|
||||
require.NotEqual(t, multiChannelGuest.Id, report.Id)
|
||||
}
|
||||
require.True(t, foundSingle, "single-channel guest not found in results")
|
||||
})
|
||||
|
||||
t.Run("should reject invalid guest_filter value", func(t *testing.T) {
|
||||
th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId)
|
||||
|
||||
options := &model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
PageSize: 50,
|
||||
},
|
||||
GuestFilter: "invalid_value",
|
||||
}
|
||||
|
||||
_, resp, err := client.GetUsersForReporting(context.Background(), options)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFillReportingBaseOptions(t *testing.T) {
|
||||
@@ -182,6 +245,45 @@ func TestFillUserReportOptions(t *testing.T) {
|
||||
|
||||
require.Equal(t, validTeamID, options.Team)
|
||||
})
|
||||
|
||||
t.Run("guest_filter all", func(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("guest_filter", "all")
|
||||
|
||||
options, appErr := fillUserReportOptions(values)
|
||||
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, "all", options.GuestFilter)
|
||||
})
|
||||
|
||||
t.Run("guest_filter single_channel", func(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("guest_filter", "single_channel")
|
||||
|
||||
options, appErr := fillUserReportOptions(values)
|
||||
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, "single_channel", options.GuestFilter)
|
||||
})
|
||||
|
||||
t.Run("guest_filter multi_channel", func(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("guest_filter", "multi_channel")
|
||||
|
||||
options, appErr := fillUserReportOptions(values)
|
||||
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, "multi_channel", options.GuestFilter)
|
||||
})
|
||||
|
||||
t.Run("guest_filter defaults to empty when not provided", func(t *testing.T) {
|
||||
values := url.Values{}
|
||||
|
||||
options, appErr := fillUserReportOptions(values)
|
||||
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, "", options.GuestFilter)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetPostsForReporting(t *testing.T) {
|
||||
|
||||
@@ -214,6 +214,7 @@ func (a *App) StartUsersBatchExport(rctx request.CTX, ro *model.UserReportOption
|
||||
"hide_inactive": strconv.FormatBool(ro.HideInactive),
|
||||
"start_at": strconv.FormatInt(startAt, 10),
|
||||
"end_at": strconv.FormatInt(endAt, 10),
|
||||
"guest_filter": ro.GuestFilter,
|
||||
}
|
||||
|
||||
// Check for existing jobs
|
||||
@@ -269,7 +270,8 @@ func (a *App) checkForExistingJobs(rctx request.CTX, options map[string]string,
|
||||
job.Data["role"] == options["role"] &&
|
||||
job.Data["team"] == options["team"] &&
|
||||
job.Data["hide_active"] == options["hide_active"] &&
|
||||
job.Data["hide_inactive"] == options["hide_inactive"] {
|
||||
job.Data["hide_inactive"] == options["hide_inactive"] &&
|
||||
job.Data["guest_filter"] == options["guest_filter"] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ func TestCheckForExistingJobs(t *testing.T) {
|
||||
"team": "",
|
||||
"hide_active": "false",
|
||||
"hide_inactive": "false",
|
||||
"guest_filter": "",
|
||||
}
|
||||
|
||||
jobType := model.JobTypeExportUsersToCSV
|
||||
@@ -147,6 +148,7 @@ func TestCheckForExistingJobs(t *testing.T) {
|
||||
"team": "",
|
||||
"hide_active": "false",
|
||||
"hide_inactive": "false",
|
||||
"guest_filter": "",
|
||||
}
|
||||
|
||||
jobType := model.JobTypeExportUsersToCSV
|
||||
@@ -178,6 +180,7 @@ func TestCheckForExistingJobs(t *testing.T) {
|
||||
"team": "",
|
||||
"hide_active": "false",
|
||||
"hide_inactive": "false",
|
||||
"guest_filter": "",
|
||||
}
|
||||
|
||||
jobType := model.JobTypeExportUsersToCSV
|
||||
@@ -189,6 +192,7 @@ func TestCheckForExistingJobs(t *testing.T) {
|
||||
"team": "",
|
||||
"hide_active": "false",
|
||||
"hide_inactive": "false",
|
||||
"guest_filter": "",
|
||||
}
|
||||
|
||||
job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, differentOptions)
|
||||
@@ -199,4 +203,36 @@ func TestCheckForExistingJobs(t *testing.T) {
|
||||
appErr := app.checkForExistingJobs(th.Context, options, jobType)
|
||||
require.Nil(t, appErr)
|
||||
})
|
||||
|
||||
t.Run("should not return error if existing job has different guest_filter", func(t *testing.T) {
|
||||
app := th.App
|
||||
options := map[string]string{
|
||||
"date_range": "last_30_days",
|
||||
"requesting_user_id": th.BasicUser.Id,
|
||||
"role": "",
|
||||
"team": "",
|
||||
"hide_active": "false",
|
||||
"hide_inactive": "false",
|
||||
"guest_filter": "single_channel",
|
||||
}
|
||||
|
||||
jobType := model.JobTypeExportUsersToCSV
|
||||
|
||||
existingJobOptions := map[string]string{
|
||||
"date_range": "last_30_days",
|
||||
"requesting_user_id": th.BasicUser.Id,
|
||||
"role": "",
|
||||
"team": "",
|
||||
"hide_active": "false",
|
||||
"hide_inactive": "false",
|
||||
"guest_filter": "multi_channel",
|
||||
}
|
||||
|
||||
job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, existingJobOptions)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, job)
|
||||
|
||||
appErr := app.checkForExistingJobs(th.Context, options, jobType)
|
||||
require.Nil(t, appErr)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func MakeWorker(jobServer *jobs.JobServer, store store.Store, app ExportUsersToC
|
||||
"LastPostDate",
|
||||
"DaysActive",
|
||||
"TotalPosts",
|
||||
"ChannelCount",
|
||||
"DeletedAt",
|
||||
},
|
||||
getData(app),
|
||||
@@ -90,6 +91,7 @@ func parseJobMetadata(data model.StringMap) (*model.UserReportOptions, error) {
|
||||
HideActive: hideActive,
|
||||
Role: data["role"],
|
||||
Team: data["team"],
|
||||
GuestFilter: data["guest_filter"],
|
||||
}
|
||||
|
||||
return &options, nil
|
||||
|
||||
@@ -2379,7 +2379,19 @@ func (us SqlUserStore) RefreshPostStatsForUsers() error {
|
||||
}
|
||||
|
||||
func applyUserReportFilter(query sq.SelectBuilder, filter *model.UserReportOptions) sq.SelectBuilder {
|
||||
query = applyRoleFilter(query, filter.Role)
|
||||
switch filter.GuestFilter {
|
||||
case model.GuestFilterAll:
|
||||
query = applyRoleFilter(query, "system_guest")
|
||||
case model.GuestFilterSingleChannel:
|
||||
query = applyRoleFilter(query, "system_guest")
|
||||
query = query.Where(sq.Expr("(SELECT COUNT(*) FROM ChannelMembers cm INNER JOIN Channels c ON c.Id = cm.ChannelId AND c.DeleteAt = 0 AND c.Type IN ('O','P') WHERE cm.UserId = Users.Id) = 1"))
|
||||
case model.GuestFilterMultipleChannel:
|
||||
query = applyRoleFilter(query, "system_guest")
|
||||
query = query.Where(sq.Expr("(SELECT COUNT(*) FROM ChannelMembers cm INNER JOIN Channels c ON c.Id = cm.ChannelId AND c.DeleteAt = 0 AND c.Type IN ('O','P') WHERE cm.UserId = Users.Id) > 1"))
|
||||
default:
|
||||
query = applyRoleFilter(query, filter.Role)
|
||||
}
|
||||
|
||||
if filter.HasNoTeam {
|
||||
query = query.Where(sq.Expr("Users.Id NOT IN (SELECT UserId FROM TeamMembers WHERE DeleteAt = 0)"))
|
||||
} else if filter.Team != "" {
|
||||
@@ -2426,6 +2438,7 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.
|
||||
"MAX(ps.LastPostDate) AS LastPostDate",
|
||||
"COUNT(ps.Day) AS DaysActive",
|
||||
"SUM(ps.NumPosts) AS TotalPosts",
|
||||
"(SELECT COUNT(*) FROM ChannelMembers cm INNER JOIN Channels c ON c.Id = cm.ChannelId AND c.DeleteAt = 0 AND c.Type IN ('O','P') WHERE cm.UserId = Users.Id) AS ChannelCount",
|
||||
)
|
||||
|
||||
sortDirection := "ASC"
|
||||
@@ -2502,7 +2515,7 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.
|
||||
}
|
||||
|
||||
parentQuery = us.getQueryBuilder().
|
||||
Select(getUsersColumnsWithName("data", "LastStatusAt", "LastPostDate", "DaysActive", "TotalPosts")...).
|
||||
Select(getUsersColumnsWithName("data", "LastStatusAt", "LastPostDate", "DaysActive", "TotalPosts", "ChannelCount")...).
|
||||
FromSelect(query, "data").
|
||||
OrderBy(filter.SortColumn+" "+reverseSortDirection, "Id")
|
||||
}
|
||||
|
||||
@@ -6843,6 +6843,470 @@ func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store, s SqlStor
|
||||
require.NoError(t, err)
|
||||
require.Len(t, userReport, 11)
|
||||
})
|
||||
|
||||
t.Run("guest channel count and filters", func(t *testing.T) {
|
||||
guestChannel1, chErr := ss.Channel().Save(rctx, &model.Channel{
|
||||
TeamId: team.Id,
|
||||
DisplayName: "Guest Channel 1",
|
||||
Name: "guest_channel_1_" + model.NewId(),
|
||||
Type: model.ChannelTypeOpen,
|
||||
}, 100)
|
||||
require.NoError(t, chErr)
|
||||
guestChannel2, chErr := ss.Channel().Save(rctx, &model.Channel{
|
||||
TeamId: team.Id,
|
||||
DisplayName: "Guest Channel 2",
|
||||
Name: "guest_channel_2_" + model.NewId(),
|
||||
Type: model.ChannelTypeOpen,
|
||||
}, 100)
|
||||
require.NoError(t, chErr)
|
||||
|
||||
guestNoChannels := &model.User{Username: "zguest_nochannel_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
|
||||
guestNoChannels, gErr := ss.User().Save(rctx, guestNoChannels)
|
||||
require.NoError(t, gErr)
|
||||
|
||||
guestOneChannel := &model.User{Username: "zguest_onechannel_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
|
||||
guestOneChannel, gErr = ss.User().Save(rctx, guestOneChannel)
|
||||
require.NoError(t, gErr)
|
||||
|
||||
guestTwoChannels := &model.User{Username: "zguest_twochannels_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
|
||||
guestTwoChannels, gErr = ss.User().Save(rctx, guestTwoChannels)
|
||||
require.NoError(t, gErr)
|
||||
|
||||
_, mErr := ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: guestChannel1.Id,
|
||||
UserId: guestOneChannel.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: guestChannel1.Id,
|
||||
UserId: guestTwoChannels.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: guestChannel2.Id,
|
||||
UserId: guestTwoChannels.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
defer func() {
|
||||
require.NoError(t, ss.User().PermanentDelete(rctx, guestNoChannels.Id))
|
||||
require.NoError(t, ss.User().PermanentDelete(rctx, guestOneChannel.Id))
|
||||
require.NoError(t, ss.User().PermanentDelete(rctx, guestTwoChannels.Id))
|
||||
require.NoError(t, ss.Channel().PermanentDelete(rctx, guestChannel1.Id))
|
||||
require.NoError(t, ss.Channel().PermanentDelete(rctx, guestChannel2.Id))
|
||||
}()
|
||||
|
||||
t.Run("should return channel count for users", func(t *testing.T) {
|
||||
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
require.NotNil(t, userReport)
|
||||
|
||||
foundOne, foundTwo, foundNone := false, false, false
|
||||
for _, report := range userReport {
|
||||
if report.Username == guestOneChannel.Username {
|
||||
foundOne = true
|
||||
require.NotNil(t, report.ChannelCount)
|
||||
require.Equal(t, 1, *report.ChannelCount)
|
||||
}
|
||||
if report.Username == guestTwoChannels.Username {
|
||||
foundTwo = true
|
||||
require.NotNil(t, report.ChannelCount)
|
||||
require.Equal(t, 2, *report.ChannelCount)
|
||||
}
|
||||
if report.Username == guestNoChannels.Username {
|
||||
foundNone = true
|
||||
require.NotNil(t, report.ChannelCount)
|
||||
require.Equal(t, 0, *report.ChannelCount)
|
||||
}
|
||||
}
|
||||
require.True(t, foundOne, "guestOneChannel not found in report")
|
||||
require.True(t, foundTwo, "guestTwoChannels not found in report")
|
||||
require.True(t, foundNone, "guestNoChannels not found in report")
|
||||
})
|
||||
|
||||
t.Run("guest filter all should return all guests", func(t *testing.T) {
|
||||
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
GuestFilter: model.GuestFilterAll,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
require.NotNil(t, userReport)
|
||||
|
||||
foundNone, foundOne, foundTwo := false, false, false
|
||||
for _, report := range userReport {
|
||||
require.Contains(t, report.Roles, "system_guest")
|
||||
switch report.Username {
|
||||
case guestNoChannels.Username:
|
||||
foundNone = true
|
||||
case guestOneChannel.Username:
|
||||
foundOne = true
|
||||
case guestTwoChannels.Username:
|
||||
foundTwo = true
|
||||
}
|
||||
}
|
||||
require.True(t, foundNone, "guestNoChannels not found in guest-all filter")
|
||||
require.True(t, foundOne, "guestOneChannel not found in guest-all filter")
|
||||
require.True(t, foundTwo, "guestTwoChannels not found in guest-all filter")
|
||||
require.Equal(t, 3, len(userReport))
|
||||
})
|
||||
|
||||
t.Run("guest filter single_channel should return guests with exactly 1 channel", func(t *testing.T) {
|
||||
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
GuestFilter: model.GuestFilterSingleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
require.NotNil(t, userReport)
|
||||
|
||||
found := false
|
||||
for _, report := range userReport {
|
||||
require.Contains(t, report.Roles, "system_guest")
|
||||
if report.Username == guestOneChannel.Username {
|
||||
found = true
|
||||
require.NotNil(t, report.ChannelCount)
|
||||
require.Equal(t, 1, *report.ChannelCount)
|
||||
}
|
||||
require.NotEqual(t, guestNoChannels.Username, report.Username)
|
||||
require.NotEqual(t, guestTwoChannels.Username, report.Username)
|
||||
}
|
||||
require.True(t, found, "single-channel guest not found in results")
|
||||
})
|
||||
|
||||
t.Run("guest filter multi_channel should return guests with more than 1 channel", func(t *testing.T) {
|
||||
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
GuestFilter: model.GuestFilterMultipleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
require.NotNil(t, userReport)
|
||||
|
||||
found := false
|
||||
for _, report := range userReport {
|
||||
require.Contains(t, report.Roles, "system_guest")
|
||||
if report.Username == guestTwoChannels.Username {
|
||||
found = true
|
||||
require.NotNil(t, report.ChannelCount)
|
||||
require.Equal(t, 2, *report.ChannelCount)
|
||||
}
|
||||
require.NotEqual(t, guestNoChannels.Username, report.Username)
|
||||
require.NotEqual(t, guestOneChannel.Username, report.Username)
|
||||
}
|
||||
require.True(t, found, "multi-channel guest not found in results")
|
||||
})
|
||||
|
||||
t.Run("archived channel should not count toward channel memberships", func(t *testing.T) {
|
||||
archivedChannel, chErr := ss.Channel().Save(rctx, &model.Channel{
|
||||
TeamId: team.Id,
|
||||
DisplayName: "Archived Channel",
|
||||
Name: "archived_channel_" + model.NewId(),
|
||||
Type: model.ChannelTypeOpen,
|
||||
}, 100)
|
||||
require.NoError(t, chErr)
|
||||
|
||||
guestWithArchived := &model.User{Username: "zguest_archived_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
|
||||
guestWithArchived, gErr = ss.User().Save(rctx, guestWithArchived)
|
||||
require.NoError(t, gErr)
|
||||
|
||||
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: guestChannel1.Id,
|
||||
UserId: guestWithArchived.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: archivedChannel.Id,
|
||||
UserId: guestWithArchived.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
err := ss.Channel().Delete(archivedChannel.Id, model.GetMillis())
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
require.NoError(t, ss.User().PermanentDelete(rctx, guestWithArchived.Id))
|
||||
require.NoError(t, ss.Channel().PermanentDelete(rctx, archivedChannel.Id))
|
||||
}()
|
||||
|
||||
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
for _, report := range userReport {
|
||||
if report.Username == guestWithArchived.Username {
|
||||
require.NotNil(t, report.ChannelCount)
|
||||
require.Equal(t, 1, *report.ChannelCount, "archived channel should not be counted")
|
||||
}
|
||||
}
|
||||
|
||||
singleReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
GuestFilter: model.GuestFilterSingleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
found := false
|
||||
for _, report := range singleReport {
|
||||
if report.Username == guestWithArchived.Username {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found, "guest with one active channel and one archived channel should appear in single-channel filter")
|
||||
|
||||
multiReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
GuestFilter: model.GuestFilterMultipleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
for _, report := range multiReport {
|
||||
require.NotEqual(t, guestWithArchived.Username, report.Username,
|
||||
"guest with one active channel and one archived channel should NOT appear in multi-channel filter")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DM and GM channels should not count toward guest channel memberships", func(t *testing.T) {
|
||||
guestWithDM := &model.User{Username: "zguest_dm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
|
||||
guestWithDM, gErr = ss.User().Save(rctx, guestWithDM)
|
||||
require.NoError(t, gErr)
|
||||
|
||||
otherUser := &model.User{Username: "zother_dm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_user"}
|
||||
otherUser, gErr = ss.User().Save(rctx, otherUser)
|
||||
require.NoError(t, gErr)
|
||||
|
||||
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: guestChannel1.Id,
|
||||
UserId: guestWithDM.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
dmChannel := &model.Channel{
|
||||
Name: model.GetDMNameFromIds(guestWithDM.Id, otherUser.Id),
|
||||
Type: model.ChannelTypeDirect,
|
||||
}
|
||||
dmMember1 := &model.ChannelMember{
|
||||
UserId: guestWithDM.Id,
|
||||
ChannelId: dmChannel.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
}
|
||||
dmMember2 := &model.ChannelMember{
|
||||
UserId: otherUser.Id,
|
||||
ChannelId: dmChannel.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
}
|
||||
dmChannel, dmErr := ss.Channel().SaveDirectChannel(rctx, dmChannel, dmMember1, dmMember2)
|
||||
require.NoError(t, dmErr)
|
||||
|
||||
defer func() {
|
||||
require.NoError(t, ss.User().PermanentDelete(rctx, guestWithDM.Id))
|
||||
require.NoError(t, ss.User().PermanentDelete(rctx, otherUser.Id))
|
||||
require.NoError(t, ss.Channel().PermanentDelete(rctx, dmChannel.Id))
|
||||
}()
|
||||
|
||||
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
for _, report := range userReport {
|
||||
if report.Username == guestWithDM.Username {
|
||||
require.NotNil(t, report.ChannelCount)
|
||||
require.Equal(t, 1, *report.ChannelCount, "DM channel should not be counted")
|
||||
}
|
||||
}
|
||||
|
||||
singleReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
GuestFilter: model.GuestFilterSingleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
found := false
|
||||
for _, report := range singleReport {
|
||||
if report.Username == guestWithDM.Username {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found, "guest with one team channel and one DM should appear in single-channel filter")
|
||||
|
||||
multiReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
GuestFilter: model.GuestFilterMultipleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
for _, report := range multiReport {
|
||||
require.NotEqual(t, guestWithDM.Username, report.Username,
|
||||
"guest with one team channel and one DM should NOT appear in multi-channel filter")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("private channels should count but GM channels should not", func(t *testing.T) {
|
||||
privateChannel, chErr := ss.Channel().Save(rctx, &model.Channel{
|
||||
TeamId: team.Id,
|
||||
DisplayName: "Private Channel",
|
||||
Name: "private_channel_" + model.NewId(),
|
||||
Type: model.ChannelTypePrivate,
|
||||
}, 100)
|
||||
require.NoError(t, chErr)
|
||||
|
||||
gmChannel, chErr := ss.Channel().Save(rctx, &model.Channel{
|
||||
DisplayName: "Group Message",
|
||||
Name: "gm_channel_" + model.NewId(),
|
||||
Type: model.ChannelTypeGroup,
|
||||
}, -1)
|
||||
require.NoError(t, chErr)
|
||||
|
||||
guestPrivateGM := &model.User{Username: "zguest_privgm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
|
||||
guestPrivateGM, gErr = ss.User().Save(rctx, guestPrivateGM)
|
||||
require.NoError(t, gErr)
|
||||
|
||||
gmOtherUser := &model.User{Username: "zother_gm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_user"}
|
||||
gmOtherUser, gErr = ss.User().Save(rctx, gmOtherUser)
|
||||
require.NoError(t, gErr)
|
||||
|
||||
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: guestChannel1.Id,
|
||||
UserId: guestPrivateGM.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: privateChannel.Id,
|
||||
UserId: guestPrivateGM.Id,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
for _, uid := range []string{guestPrivateGM.Id, gmOtherUser.Id} {
|
||||
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: gmChannel.Id,
|
||||
UserId: uid,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
require.NoError(t, ss.User().PermanentDelete(rctx, guestPrivateGM.Id))
|
||||
require.NoError(t, ss.User().PermanentDelete(rctx, gmOtherUser.Id))
|
||||
require.NoError(t, ss.Channel().PermanentDelete(rctx, privateChannel.Id))
|
||||
require.NoError(t, ss.Channel().PermanentDelete(rctx, gmChannel.Id))
|
||||
}()
|
||||
|
||||
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
for _, report := range userReport {
|
||||
if report.Username == guestPrivateGM.Username {
|
||||
require.NotNil(t, report.ChannelCount)
|
||||
require.Equal(t, 2, *report.ChannelCount, "should count open + private, not GM")
|
||||
}
|
||||
}
|
||||
|
||||
multiReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
GuestFilter: model.GuestFilterMultipleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
found := false
|
||||
for _, report := range multiReport {
|
||||
if report.Username == guestPrivateGM.Username {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found, "guest with open + private channels should appear in multi-channel filter")
|
||||
|
||||
singleReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
PageSize: 200,
|
||||
},
|
||||
GuestFilter: model.GuestFilterSingleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
for _, report := range singleReport {
|
||||
require.NotEqual(t, guestPrivateGM.Username, report.Username,
|
||||
"guest with 2 team channels should NOT appear in single-channel filter")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("guest filter count query should match", func(t *testing.T) {
|
||||
allCount, rErr := ss.User().GetUserCountForReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
},
|
||||
GuestFilter: model.GuestFilterAll,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
require.Equal(t, int64(3), allCount)
|
||||
|
||||
singleCount, rErr := ss.User().GetUserCountForReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
},
|
||||
GuestFilter: model.GuestFilterSingleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
require.Equal(t, int64(1), singleCount)
|
||||
|
||||
multiCount, rErr := ss.User().GetUserCountForReport(&model.UserReportOptions{
|
||||
ReportingBaseOptions: model.ReportingBaseOptions{
|
||||
SortColumn: "Username",
|
||||
},
|
||||
GuestFilter: model.GuestFilterMultipleChannel,
|
||||
})
|
||||
require.NoError(t, rErr)
|
||||
require.Equal(t, int64(1), multiCount)
|
||||
|
||||
// guestNoChannels has 0 active channels, so it appears in "all" but
|
||||
// neither "single" nor "multi"; the sum is strictly less than allCount.
|
||||
require.Equal(t, int64(2), singleCount+multiCount)
|
||||
require.Less(t, singleCount+multiCount, allCount)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func testMfaUsedTimestamps(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
|
||||
@@ -11656,6 +11656,10 @@
|
||||
"id": "model.user_access_token.is_valid.user_id.app_error",
|
||||
"translation": "Invalid user id."
|
||||
},
|
||||
{
|
||||
"id": "model.user_report_options.is_valid.invalid_guest_filter",
|
||||
"translation": "Provided guest filter is not valid."
|
||||
},
|
||||
{
|
||||
"id": "model.user_report_options.is_valid.invalid_sort_column",
|
||||
"translation": "Provided sort column is not valid."
|
||||
|
||||
@@ -1983,6 +1983,12 @@ func (c *Client4) GetUsersForReporting(ctx context.Context, options *UserReportO
|
||||
if options.DateRange != "" {
|
||||
values.Set("date_range", options.DateRange)
|
||||
}
|
||||
if options.GuestFilter != "" {
|
||||
values.Set("guest_filter", options.GuestFilter)
|
||||
}
|
||||
if options.SearchTerm != "" {
|
||||
values.Set("search_term", options.SearchTerm)
|
||||
}
|
||||
|
||||
r, err := c.doAPIGetWithQuery(ctx, c.reportsRoute().Join("users"), values, "")
|
||||
if err != nil {
|
||||
|
||||
@@ -17,12 +17,18 @@ const (
|
||||
ReportDurationLast6Months = "last_6_months"
|
||||
|
||||
ReportingMaxPageSize = 100
|
||||
|
||||
GuestFilterAll = "all"
|
||||
GuestFilterSingleChannel = "single_channel"
|
||||
GuestFilterMultipleChannel = "multi_channel"
|
||||
)
|
||||
|
||||
var (
|
||||
ReportExportFormats = []string{"csv"}
|
||||
|
||||
UserReportSortColumns = []string{"CreateAt", "Username", "FirstName", "LastName", "Nickname", "Email", "Roles"}
|
||||
|
||||
AllowedGuestFilters = []string{GuestFilterAll, GuestFilterSingleChannel, GuestFilterMultipleChannel}
|
||||
)
|
||||
|
||||
type ReportableObject interface {
|
||||
@@ -76,11 +82,13 @@ func (options *ReportingBaseOptions) IsValid() *AppError {
|
||||
type UserReportQuery struct {
|
||||
User
|
||||
UserPostStats
|
||||
ChannelCount *int
|
||||
}
|
||||
|
||||
type UserReport struct {
|
||||
User
|
||||
UserPostStats
|
||||
ChannelCount *int `json:"channel_count,omitempty"`
|
||||
}
|
||||
|
||||
func (u *UserReport) ToReport() []string {
|
||||
@@ -100,6 +108,10 @@ func (u *UserReport) ToReport() []string {
|
||||
if u.TotalPosts != nil {
|
||||
totalPosts = strconv.Itoa(*u.TotalPosts)
|
||||
}
|
||||
channelCount := ""
|
||||
if u.ChannelCount != nil {
|
||||
channelCount = strconv.Itoa(*u.ChannelCount)
|
||||
}
|
||||
lastLogin := ""
|
||||
if u.LastLogin > 0 {
|
||||
lastLogin = time.UnixMilli(u.LastLogin).String()
|
||||
@@ -122,6 +134,7 @@ func (u *UserReport) ToReport() []string {
|
||||
lastPostDate,
|
||||
daysActive,
|
||||
totalPosts,
|
||||
channelCount,
|
||||
deleteAt,
|
||||
}
|
||||
}
|
||||
@@ -134,6 +147,7 @@ type UserReportOptions struct {
|
||||
HideActive bool
|
||||
HideInactive bool
|
||||
SearchTerm string
|
||||
GuestFilter string
|
||||
}
|
||||
|
||||
func (u *UserReportOptions) IsValid() *AppError {
|
||||
@@ -146,6 +160,10 @@ func (u *UserReportOptions) IsValid() *AppError {
|
||||
return NewAppError("UserReportOptions.IsValid", "model.user_report_options.is_valid.invalid_sort_column", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if u.GuestFilter != "" && !slices.Contains(AllowedGuestFilters, u.GuestFilter) {
|
||||
return NewAppError("UserReportOptions.IsValid", "model.user_report_options.is_valid.invalid_guest_filter", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -154,6 +172,7 @@ func (u *UserReportQuery) ToReport() *UserReport {
|
||||
return &UserReport{
|
||||
User: u.User,
|
||||
UserPostStats: u.UserPostStats,
|
||||
ChannelCount: u.ChannelCount,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum ColumnNames {
|
||||
lastPostDate = 'lastPostDateColumn',
|
||||
daysActive = 'daysActiveColumn',
|
||||
totalPosts = 'totalPostsColumn',
|
||||
channelCount = 'channelCountColumn',
|
||||
actions = 'actionsColumn',
|
||||
}
|
||||
|
||||
@@ -24,7 +25,9 @@ export enum RoleFilters {
|
||||
Any = 'any',
|
||||
Admin = 'system_admin',
|
||||
Member = 'system_user',
|
||||
Guest = 'system_guest',
|
||||
GuestAll = 'system_guest',
|
||||
GuestSingleChannel = 'guest_single_channel',
|
||||
GuestMultiChannel = 'guest_multi_channel',
|
||||
}
|
||||
|
||||
export enum TeamFilters {
|
||||
|
||||
@@ -383,6 +383,21 @@ function SystemUsers(props: Props) {
|
||||
enablePinning: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: ColumnNames.channelCount,
|
||||
accessorKey: 'channel_count',
|
||||
header: formatMessage({
|
||||
id: 'admin.system_users.list.channelCount',
|
||||
defaultMessage: 'Channel count',
|
||||
}),
|
||||
cell: (info: CellContext<UserReport, number | undefined>) => info.getValue() ?? null,
|
||||
meta: {
|
||||
isNumeric: true,
|
||||
},
|
||||
enableHiding: true,
|
||||
enablePinning: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: ColumnNames.actions,
|
||||
accessorKey: 'actions',
|
||||
|
||||
+7
@@ -80,6 +80,13 @@ export function SystemUsersColumnTogglerMenu(props: Props) {
|
||||
defaultMessage='Messages posted'
|
||||
/>
|
||||
);
|
||||
case ColumnNames.channelCount:
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='admin.system_users.list.channelCount'
|
||||
defaultMessage='Channel count'
|
||||
/>
|
||||
);
|
||||
case ColumnNames.actions:
|
||||
return (
|
||||
<FormattedMessage
|
||||
|
||||
+6
-2
@@ -83,8 +83,12 @@ export function SystemUsersFilterPopover(props: Props) {
|
||||
filterRole = RoleFilters.Admin;
|
||||
} else if (roleFilter === RoleFilters.Member) {
|
||||
filterRole = RoleFilters.Member;
|
||||
} else if (roleFilter === RoleFilters.Guest) {
|
||||
filterRole = RoleFilters.Guest;
|
||||
} else if (roleFilter === RoleFilters.GuestAll) {
|
||||
filterRole = RoleFilters.GuestAll;
|
||||
} else if (roleFilter === RoleFilters.GuestSingleChannel) {
|
||||
filterRole = RoleFilters.GuestSingleChannel;
|
||||
} else if (roleFilter === RoleFilters.GuestMultiChannel) {
|
||||
filterRole = RoleFilters.GuestMultiChannel;
|
||||
}
|
||||
|
||||
setFilterState({...filterState, filterRole});
|
||||
|
||||
+59
-34
@@ -3,6 +3,7 @@
|
||||
|
||||
import React, {useMemo, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import type {GroupBase} from 'react-select';
|
||||
|
||||
import DropdownInput from 'components/dropdown_input';
|
||||
|
||||
@@ -24,40 +25,64 @@ type Props = {
|
||||
export function SystemUsersFilterRole(props: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: RoleFilters.Any,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.any',
|
||||
defaultMessage: 'Any',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: RoleFilters.Admin,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.system_admin',
|
||||
defaultMessage: 'System Admin',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: RoleFilters.Member,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.system_user',
|
||||
defaultMessage: 'Member',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: RoleFilters.Guest,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.system_guest',
|
||||
defaultMessage: 'Guest',
|
||||
}),
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
const anyOption: OptionType = useMemo(() => ({
|
||||
value: RoleFilters.Any,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.any',
|
||||
defaultMessage: 'Any',
|
||||
}),
|
||||
}), [formatMessage]);
|
||||
|
||||
const [value, setValue] = useState(() => getDefaultSelectedValueFromList(props.initialValue, options));
|
||||
const roleOptions: OptionType[] = useMemo(() => [
|
||||
{
|
||||
value: RoleFilters.Admin,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.system_admin',
|
||||
defaultMessage: 'System Admin',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: RoleFilters.Member,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.system_user',
|
||||
defaultMessage: 'Member',
|
||||
}),
|
||||
},
|
||||
], [formatMessage]);
|
||||
|
||||
const guestOptions: OptionType[] = useMemo(() => [
|
||||
{
|
||||
value: RoleFilters.GuestAll,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.system_guest',
|
||||
defaultMessage: 'Guests (all)',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: RoleFilters.GuestSingleChannel,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.guest_single_channel',
|
||||
defaultMessage: 'Guests in a single channel',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: RoleFilters.GuestMultiChannel,
|
||||
label: formatMessage({
|
||||
id: 'admin.system_users.filters.role.guest_multi_channel',
|
||||
defaultMessage: 'Guests in multiple channels',
|
||||
}),
|
||||
},
|
||||
], [formatMessage]);
|
||||
|
||||
const flatOptions = useMemo(() => [anyOption, ...roleOptions, ...guestOptions], [anyOption, roleOptions, guestOptions]);
|
||||
|
||||
const groupedOptions: Array<GroupBase<OptionType>> = useMemo(() => [
|
||||
{label: '', options: [anyOption]},
|
||||
{label: '', options: roleOptions},
|
||||
{label: '', options: guestOptions},
|
||||
], [anyOption, roleOptions, guestOptions]);
|
||||
|
||||
const [value, setValue] = useState(() => getDefaultSelectedValueFromList(props.initialValue, flatOptions));
|
||||
|
||||
function handleChange(value: OptionType) {
|
||||
setValue(value);
|
||||
@@ -70,7 +95,7 @@ export function SystemUsersFilterRole(props: Props) {
|
||||
name='filterRole'
|
||||
isSearchable={false}
|
||||
legend={formatMessage({id: 'admin.system_users.filters.role.title', defaultMessage: 'Role'})}
|
||||
options={options}
|
||||
options={groupedOptions}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GuestFilter} from '@mattermost/types/reports';
|
||||
import type {UserReport} from '@mattermost/types/reports';
|
||||
|
||||
import {ColumnNames, StatusFilter} from '../constants';
|
||||
import {ColumnNames, RoleFilters, StatusFilter} from '../constants';
|
||||
|
||||
import {getSortColumnForOptions, getSortDirectionForOptions, getSortableColumnValueBySortColumn, getStatusFilterOption} from './index';
|
||||
import {getSortColumnForOptions, getSortDirectionForOptions, getSortableColumnValueBySortColumn, getStatusFilterOption, getRoleFilterOption, convertTableOptionsToUserReportOptions} from './index';
|
||||
|
||||
describe('getSortColumnForOptions', () => {
|
||||
it('should return correct sort column for email', () => {
|
||||
@@ -73,3 +74,72 @@ describe('getStatusFilterOption', () => {
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoleFilterOption', () => {
|
||||
it('should return undefined for both when role is Any', () => {
|
||||
const result = getRoleFilterOption(RoleFilters.Any);
|
||||
expect(result).toEqual({role_filter: undefined, guest_filter: undefined});
|
||||
});
|
||||
|
||||
it('should return undefined for both when role is not provided', () => {
|
||||
const result = getRoleFilterOption();
|
||||
expect(result).toEqual({role_filter: undefined, guest_filter: undefined});
|
||||
});
|
||||
|
||||
it('should return role_filter for Admin', () => {
|
||||
const result = getRoleFilterOption(RoleFilters.Admin);
|
||||
expect(result).toEqual({role_filter: 'system_admin', guest_filter: undefined});
|
||||
});
|
||||
|
||||
it('should return role_filter for Member', () => {
|
||||
const result = getRoleFilterOption(RoleFilters.Member);
|
||||
expect(result).toEqual({role_filter: 'system_user', guest_filter: undefined});
|
||||
});
|
||||
|
||||
it('should return guest_filter all for GuestAll', () => {
|
||||
const result = getRoleFilterOption(RoleFilters.GuestAll);
|
||||
expect(result).toEqual({role_filter: undefined, guest_filter: GuestFilter.All});
|
||||
});
|
||||
|
||||
it('should return guest_filter single_channel for GuestSingleChannel', () => {
|
||||
const result = getRoleFilterOption(RoleFilters.GuestSingleChannel);
|
||||
expect(result).toEqual({role_filter: undefined, guest_filter: GuestFilter.SingleChannel});
|
||||
});
|
||||
|
||||
it('should return guest_filter multi_channel for GuestMultiChannel', () => {
|
||||
const result = getRoleFilterOption(RoleFilters.GuestMultiChannel);
|
||||
expect(result).toEqual({role_filter: undefined, guest_filter: GuestFilter.MultipleChannel});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertTableOptionsToUserReportOptions', () => {
|
||||
it('should set guest_filter and not role_filter when filterRole is GuestSingleChannel', () => {
|
||||
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.GuestSingleChannel});
|
||||
expect(result.guest_filter).toBe(GuestFilter.SingleChannel);
|
||||
expect(result.role_filter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set guest_filter and not role_filter when filterRole is GuestMultiChannel', () => {
|
||||
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.GuestMultiChannel});
|
||||
expect(result.guest_filter).toBe(GuestFilter.MultipleChannel);
|
||||
expect(result.role_filter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set guest_filter and not role_filter when filterRole is GuestAll', () => {
|
||||
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.GuestAll});
|
||||
expect(result.guest_filter).toBe(GuestFilter.All);
|
||||
expect(result.role_filter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set role_filter and not guest_filter when filterRole is Admin', () => {
|
||||
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.Admin});
|
||||
expect(result.role_filter).toBe('system_admin');
|
||||
expect(result.guest_filter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not set role_filter or guest_filter when filterRole is Any', () => {
|
||||
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.Any});
|
||||
expect(result.role_filter).toBeUndefined();
|
||||
expect(result.guest_filter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {SortingState} from '@tanstack/react-table';
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {UserReportSortColumns, ReportSortDirection} from '@mattermost/types/reports';
|
||||
import {UserReportSortColumns, ReportSortDirection, GuestFilter} from '@mattermost/types/reports';
|
||||
import type {UserReportOptions, UserReport} from '@mattermost/types/reports';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
|
||||
@@ -186,11 +186,20 @@ export function getDefaultSelectedTeam(teamId: Team['id'] | string, label?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleFilterOption(role?: string): Pick<UserReportOptions, 'role_filter'> {
|
||||
export function getRoleFilterOption(role?: string): Pick<UserReportOptions, 'role_filter' | 'guest_filter'> {
|
||||
if (!role || role === RoleFilters.Any) {
|
||||
return {role_filter: undefined};
|
||||
return {role_filter: undefined, guest_filter: undefined};
|
||||
}
|
||||
return {role_filter: role};
|
||||
if (role === RoleFilters.GuestAll) {
|
||||
return {role_filter: undefined, guest_filter: GuestFilter.All};
|
||||
}
|
||||
if (role === RoleFilters.GuestSingleChannel) {
|
||||
return {role_filter: undefined, guest_filter: GuestFilter.SingleChannel};
|
||||
}
|
||||
if (role === RoleFilters.GuestMultiChannel) {
|
||||
return {role_filter: undefined, guest_filter: GuestFilter.MultipleChannel};
|
||||
}
|
||||
return {role_filter: role, guest_filter: undefined};
|
||||
}
|
||||
|
||||
export function getSearchFilterOption(search?: string): Pick<UserReportOptions, 'search_term'> {
|
||||
|
||||
@@ -33,6 +33,10 @@ $dropdown_input_index: 999999;
|
||||
background-color: var(--center-channel-bg) !important;
|
||||
}
|
||||
|
||||
.DropDown__menu-list > .DropDown__group:first-child .DropDown__group-heading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DropDown__single-value {
|
||||
color: var(--center-channel-color) !important;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,19 @@ const baseStyles = {
|
||||
...provided,
|
||||
zIndex: 100,
|
||||
}),
|
||||
group: (provided) => ({
|
||||
...provided,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}),
|
||||
groupHeading: (provided) => ({
|
||||
...provided,
|
||||
height: 1,
|
||||
margin: '4px 0',
|
||||
padding: 0,
|
||||
fontSize: 0,
|
||||
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
|
||||
}),
|
||||
} satisfies StylesConfig;
|
||||
|
||||
const IndicatorsContainer = (props: any) => {
|
||||
|
||||
@@ -3040,8 +3040,10 @@
|
||||
"admin.system_users.exportButton.notLicensed.hint": "This feature is available on the professional plan",
|
||||
"admin.system_users.exportButton.notLicensed.title": "Professional feature",
|
||||
"admin.system_users.filters.role.any": "Any",
|
||||
"admin.system_users.filters.role.guest_multi_channel": "Guests in multiple channels",
|
||||
"admin.system_users.filters.role.guest_single_channel": "Guests in a single channel",
|
||||
"admin.system_users.filters.role.system_admin": "System Admin",
|
||||
"admin.system_users.filters.role.system_guest": "Guest",
|
||||
"admin.system_users.filters.role.system_guest": "Guests (all)",
|
||||
"admin.system_users.filters.role.system_user": "Member",
|
||||
"admin.system_users.filters.role.title": "Role",
|
||||
"admin.system_users.filters.status.active": "Activated users",
|
||||
@@ -3079,6 +3081,7 @@
|
||||
"admin.system_users.list.actions.userGuest": "Guest",
|
||||
"admin.system_users.list.actions.userMember": "Member",
|
||||
"admin.system_users.list.caption": "System Users",
|
||||
"admin.system_users.list.channelCount": "Channel count",
|
||||
"admin.system_users.list.daysActive": "Days active",
|
||||
"admin.system_users.list.email": "Email",
|
||||
"admin.system_users.list.lastActivity": "Last activity",
|
||||
|
||||
@@ -66,7 +66,9 @@ export const adminConsoleUserManagementTablePropertiesInitialState: AdminConsole
|
||||
cursorDirection: CursorPaginationDirection.next,
|
||||
cursorUserId: '',
|
||||
cursorColumnValue: '',
|
||||
columnVisibility: {},
|
||||
columnVisibility: {
|
||||
channelCountColumn: false,
|
||||
},
|
||||
searchTerm: '',
|
||||
filterTeam: '',
|
||||
filterTeamLabel: '',
|
||||
|
||||
@@ -24,6 +24,12 @@ export enum ReportDuration {
|
||||
Last6Months = 'last_6_months',
|
||||
}
|
||||
|
||||
export enum GuestFilter {
|
||||
All = 'all',
|
||||
SingleChannel = 'single_channel',
|
||||
MultipleChannel = 'multi_channel',
|
||||
}
|
||||
|
||||
export enum CursorPaginationDirection {
|
||||
'prev' = 'prev',
|
||||
'next' = 'next',
|
||||
@@ -36,6 +42,7 @@ export type UserReportFilter = {
|
||||
hide_active?: boolean;
|
||||
hide_inactive?: boolean;
|
||||
search_term?: string;
|
||||
guest_filter?: string;
|
||||
}
|
||||
|
||||
export type UserReportOptions = UserReportFilter & {
|
||||
@@ -81,4 +88,5 @@ export type UserReport = UserProfile & {
|
||||
last_post_date?: number;
|
||||
days_active?: number;
|
||||
total_posts?: number;
|
||||
channel_count?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user