[MM-56028] Decouple system_users components for drop in replacement of list (#25613)

This commit is contained in:
M-ZubairAhmed
2023-12-08 17:29:58 +00:00
committed by GitHub
parent b1e745894b
commit c7f24dfa06
13 changed files with 330 additions and 114 deletions

View File

@@ -25,6 +25,26 @@ exports[`components/admin_console/system_users should match default snapshot 1`]
<div
className="more-modal__list member-list-holder"
>
<div
className="system-users__filter-row"
>
<SystemUsersSearch
onChange={[Function]}
onSearch={[Function]}
value=""
/>
<SystemUsersFilterTeam
onChange={[Function]}
onFilter={[Function]}
options={Array []}
value=""
/>
<SystemUsersFilterRole
onChange={[Function]}
onFilter={[Function]}
value=""
/>
</div>
<Connect(SystemUsersList)
enableUserAccessTokens={false}
experimentalEnableAuthenticationTransfer={false}
@@ -33,7 +53,6 @@ exports[`components/admin_console/system_users should match default snapshot 1`]
mfaEnabled={false}
nextPage={[Function]}
onTermChange={[Function]}
renderFilterRow={[Function]}
search={[Function]}
teamId=""
teams={Array []}

View File

@@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
import {Constants, SearchUserTeamFilter, UserFilters} from 'utils/constants';
import SystemUsers from './system_users';
@@ -43,14 +43,14 @@ describe('components/admin_console/system_users', () => {
test('should match default snapshot', () => {
const props = defaultProps;
const wrapper = shallowWithIntl(<SystemUsers {...props}/>);
const wrapper = shallow(<SystemUsers {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('loadDataForTeam() should have called getProfiles', async () => {
const getProfiles = jest.fn().mockResolvedValue(undefined);
const props = {...defaultProps, actions: {...defaultProps.actions, getProfiles}};
const wrapper = shallowWithIntl(<SystemUsers {...props}/>);
const wrapper = shallow(<SystemUsers {...props}/>);
wrapper.setState({loading: true});
@@ -66,7 +66,7 @@ describe('components/admin_console/system_users', () => {
test('loadDataForTeam() should have called loadProfilesWithoutTeam', async () => {
const loadProfilesWithoutTeam = jest.fn().mockResolvedValue(undefined);
const props = {...defaultProps, actions: {...defaultProps.actions, loadProfilesWithoutTeam}};
const wrapper = shallowWithIntl(<SystemUsers {...props}/>);
const wrapper = shallow(<SystemUsers {...props}/>);
wrapper.setState({loading: true});
@@ -91,7 +91,7 @@ describe('components/admin_console/system_users', () => {
teamId: SearchUserTeamFilter.ALL_USERS,
actions: {...defaultProps.actions, getProfiles},
};
const wrapper = shallowWithIntl(<SystemUsers {...props}/>);
const wrapper = shallow(<SystemUsers {...props}/>);
wrapper.setState({loading: true});
@@ -111,7 +111,7 @@ describe('components/admin_console/system_users', () => {
teamId: SearchUserTeamFilter.NO_TEAM,
actions: {...defaultProps.actions, loadProfilesWithoutTeam},
};
const wrapper = shallowWithIntl(<SystemUsers {...props}/>);
const wrapper = shallow(<SystemUsers {...props}/>);
wrapper.setState({loading: true});
@@ -131,7 +131,7 @@ describe('components/admin_console/system_users', () => {
teamId: SearchUserTeamFilter.NO_TEAM,
actions: {...defaultProps.actions, searchProfiles},
};
const wrapper = shallowWithIntl(<SystemUsers {...props}/>);
const wrapper = shallow(<SystemUsers {...props}/>);
const instance = wrapper.instance() as SystemUserClass;
@@ -149,7 +149,7 @@ describe('components/admin_console/system_users', () => {
teamId: SearchUserTeamFilter.NO_TEAM,
actions: {...defaultProps.actions, searchProfiles},
};
const wrapper = shallowWithIntl(<SystemUsers {...props}/>);
const wrapper = shallow(<SystemUsers {...props}/>);
const instance = wrapper.instance() as SystemUserClass;

View File

@@ -1,8 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ChangeEvent} from 'react';
import {FormattedMessage, type IntlShape, injectIntl} from 'react-intl';
import React from 'react';
import type {ChangeEvent} from 'react';
import {FormattedMessage} from 'react-intl';
import type {ServerError} from '@mattermost/types/errors';
import type {Team} from '@mattermost/types/teams';
@@ -13,19 +14,20 @@ import type {ActionFunc} from 'mattermost-redux/types/actions';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import {Constants, UserSearchOptions, SearchUserTeamFilter, UserFilters} from 'utils/constants';
import {Constants, UserSearchOptions, SearchUserTeamFilter} from 'utils/constants';
import {getUserOptionsFromFilter, searchUserOptionsFromFilter} from 'utils/filter_users';
import SystemUsersList from './list';
import RevokeSessionsButton from './revoke_sessions_button';
import SystemUsersFilterRole from './system_users_filter_role';
import SystemUsersFilterTeam from './system_users_filter_team';
import SystemUsersList from './system_users_list';
import SystemUsersSearch from './system_users_search';
const USER_ID_LENGTH = 26;
const USERS_PER_PAGE = 50;
type Props = {
intl: IntlShape;
/**
* Array of team objects
*/
@@ -168,6 +170,14 @@ export class SystemUsers extends React.PureComponent<Props, State> {
this.props.actions.setSystemUsersSearch(term, this.props.teamId, this.props.filter);
};
handleSearchFiltersChange = ({searchTerm, teamId, filter}: {searchTerm?: string; teamId?: string; filter?: string}) => {
const changedSearchTerm = typeof searchTerm === 'undefined' ? this.props.searchTerm : searchTerm;
const changedTeamId = typeof teamId === 'undefined' ? this.props.teamId : teamId;
const changedFilter = typeof filter === 'undefined' ? this.props.filter : filter;
this.props.actions.setSystemUsersSearch(changedSearchTerm, changedTeamId, changedFilter);
};
nextPage = async (page: number) => {
const {teamId, filter} = this.props;
@@ -190,6 +200,56 @@ export class SystemUsers extends React.PureComponent<Props, State> {
this.setState({loading: false});
};
onSearch = async (term: string) => {
this.setState({loading: true});
const options = {
...searchUserOptionsFromFilter(this.props.filter),
...this.props.teamId && {team_id: this.props.teamId},
...this.props.teamId === SearchUserTeamFilter.NO_TEAM && {
[UserSearchOptions.WITHOUT_TEAM]: true,
},
allow_inactive: true,
};
const {data: profiles} = await this.props.actions.searchProfiles(term, options);
if (profiles.length === 0 && term.length === USER_ID_LENGTH) {
await this.getUserByTokenOrId(term);
}
this.setState({loading: false});
};
onFilter = async ({teamId, filter}: {teamId?: string; filter?: string}) => {
if (this.props.searchTerm) {
this.onSearch(this.props.searchTerm);
return;
}
this.setState({loading: true});
const newTeamId = typeof teamId === 'undefined' ? this.props.teamId : teamId;
const newFilter = typeof filter === 'undefined' ? this.props.filter : filter;
const options = getUserOptionsFromFilter(newFilter);
if (newTeamId === SearchUserTeamFilter.ALL_USERS) {
await Promise.all([
this.props.actions.getProfiles(0, Constants.PROFILE_CHUNK_SIZE, options),
this.props.actions.getFilteredUsersStats({include_bots: false, include_deleted: true}),
]);
} else if (newTeamId === SearchUserTeamFilter.NO_TEAM) {
await this.props.actions.loadProfilesWithoutTeam(0, Constants.PROFILE_CHUNK_SIZE, options);
} else {
await Promise.all([
this.props.actions.loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, newTeamId, options),
this.props.actions.getTeamStats(newTeamId),
]);
}
this.setState({loading: false});
};
doSearch = debounce(async (term, teamId = this.props.teamId, filter = this.props.filter) => {
if (!term) {
return;
@@ -238,67 +298,6 @@ export class SystemUsers extends React.PureComponent<Props, State> {
this.getUserById(id);
};
renderFilterRow = (doSearch: ((event: React.FormEvent<HTMLInputElement>) => void) | undefined) => {
const teams = this.props.teams.map((team) => (
<option
key={team.id}
value={team.id}
>
{team.display_name}
</option>
));
return (
<div className='system-users__filter-row'>
<div className='system-users__filter'>
<input
id='searchUsers'
className='form-control filter-textbox'
placeholder={this.props.intl.formatMessage({id: 'filtered_user_list.search', defaultMessage: 'Search users'})}
onInput={doSearch}
/>
</div>
<label>
<span className='system-users__team-filter-label'>
<FormattedMessage
id='filtered_user_list.team'
defaultMessage='Team:'
/>
</span>
<select
className='form-control system-users__team-filter'
onChange={this.handleTeamChange}
value={this.props.teamId}
>
<option value={SearchUserTeamFilter.ALL_USERS}>{this.props.intl.formatMessage({id: 'admin.system_users.allUsers', defaultMessage: 'All Users'})}</option>
<option value={SearchUserTeamFilter.NO_TEAM}>{this.props.intl.formatMessage({id: 'admin.system_users.noTeams', defaultMessage: 'No Teams'})}</option>
{teams}
</select>
</label>
<label>
<span className='system-users__filter-label'>
<FormattedMessage
id='filtered_user_list.userStatus'
defaultMessage='User Status:'
/>
</span>
<select
id='selectUserStatus'
className='form-control system-users__filter'
value={this.props.filter}
onChange={this.handleFilterChange}
>
<option value=''>{this.props.intl.formatMessage({id: 'admin.system_users.allUsers', defaultMessage: 'All Users'})}</option>
<option value={UserFilters.SYSTEM_ADMIN}>{this.props.intl.formatMessage({id: 'admin.system_users.system_admin', defaultMessage: 'System Admin'})}</option>
<option value={UserFilters.SYSTEM_GUEST}>{this.props.intl.formatMessage({id: 'admin.system_users.guest', defaultMessage: 'Guest'})}</option>
<option value={UserFilters.ACTIVE}>{this.props.intl.formatMessage({id: 'admin.system_users.active', defaultMessage: 'Active'})}</option>
<option value={UserFilters.INACTIVE}>{this.props.intl.formatMessage({id: 'admin.system_users.inactive', defaultMessage: 'Inactive'})}</option>
</select>
</label>
</div>
);
};
render() {
return (
<div className='wrapper--fixed'>
@@ -306,18 +305,33 @@ export class SystemUsers extends React.PureComponent<Props, State> {
<FormattedMessage
id='admin.system_users.title'
defaultMessage='{siteName} Users'
values={{
siteName: this.props.siteName,
}}
values={{siteName: this.props.siteName}}
/>
<RevokeSessionsButton/>
</AdminHeader>
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
<div className='more-modal__list member-list-holder'>
<div className='system-users__filter-row'>
<SystemUsersSearch
value={this.props.searchTerm}
onChange={this.handleSearchFiltersChange}
onSearch={this.onSearch}
/>
<SystemUsersFilterTeam
options={this.props.teams}
value={this.props.teamId}
onChange={this.handleSearchFiltersChange}
onFilter={this.onFilter}
/>
<SystemUsersFilterRole
value={this.props.filter}
onChange={this.handleSearchFiltersChange}
onFilter={this.onFilter}
/>
</div>
<SystemUsersList
loading={this.state.loading}
renderFilterRow={this.renderFilterRow}
search={this.doSearch}
nextPage={this.nextPage}
usersPerPage={USERS_PER_PAGE}
@@ -339,4 +353,4 @@ export class SystemUsers extends React.PureComponent<Props, State> {
}
}
export default injectIntl(SystemUsers);
export default SystemUsers;

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ChangeEvent} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {UserFilters} from 'utils/constants';
type Props = {
value?: string;
onChange: ({searchTerm, teamId, filter}: {searchTerm?: string; teamId?: string; filter?: string}) => void;
onFilter: ({teamId, filter}: {teamId?: string; filter?: string}) => Promise<void>;
};
function SystemUsersFilterRole(props: Props) {
const {formatMessage} = useIntl();
function handleChange(e: ChangeEvent<HTMLSelectElement>) {
const filter = e?.target?.value ?? '';
props.onChange({filter});
props.onFilter({filter});
}
return (
<label>
<span className='system-users__filter-label'>
<FormattedMessage
id='filtered_user_list.userStatus'
defaultMessage='User Status:'
/>
</span>
<select
id='selectUserStatus'
className='form-control system-users__filter'
value={props.value}
onChange={handleChange}
>
<option value=''>
{formatMessage({id: 'admin.system_users.allUsers', defaultMessage: 'All Users'})}
</option>
<option value={UserFilters.SYSTEM_ADMIN}>
{formatMessage({id: 'admin.system_users.system_admin', defaultMessage: 'System Admin'})}
</option>
<option value={UserFilters.SYSTEM_GUEST}>
{formatMessage({id: 'admin.system_users.guest', defaultMessage: 'Guest'})}
</option>
<option value={UserFilters.ACTIVE}>
{formatMessage({id: 'admin.system_users.active', defaultMessage: 'Active'})}
</option>
<option value={UserFilters.INACTIVE}>
{formatMessage({id: 'admin.system_users.inactive', defaultMessage: 'Inactive'})}
</option>
</select>
</label>
);
}
export default SystemUsersFilterRole;

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChangeEvent} from 'react';
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {Team} from '@mattermost/types/teams';
import {SearchUserTeamFilter} from 'utils/constants';
type Props = {
options?: Team[];
value?: string;
onChange: ({searchTerm, teamId, filter}: {searchTerm?: string; teamId?: string; filter?: string}) => void;
onFilter: ({teamId, filter}: {teamId?: string; filter?: string}) => Promise<void>;
};
function SystemUsersFilterTeam(props: Props) {
const {formatMessage} = useIntl();
function handleChange(e: ChangeEvent<HTMLSelectElement>) {
const teamId = e?.target?.value ?? '';
props.onChange({teamId});
props.onFilter({teamId});
}
return (
<label>
<span className='system-users__team-filter-label'>
<FormattedMessage
id='filtered_user_list.team'
defaultMessage='Team:'
/>
</span>
<select
className='form-control system-users__team-filter'
value={props.value}
onChange={handleChange}
>
<option value={SearchUserTeamFilter.ALL_USERS}>
{formatMessage({
id: 'admin.system_users.allUsers',
defaultMessage: 'All Users',
})}
</option>
<option value={SearchUserTeamFilter.NO_TEAM}>
{formatMessage({
id: 'admin.system_users.noTeams',
defaultMessage: 'No Teams',
})}
</option>
{props.options?.map((team) => (
<option
key={team.id}
value={team.id}
>
{team.display_name}
</option>
))}
</select>
</label>
);
}
export default SystemUsersFilterTeam;

View File

@@ -33,6 +33,7 @@ exports[`components/admin_console/system_users/list should match default snapsho
isDisabled={false}
mfaEnabled={false}
nextPage={[Function]}
noBuiltInFilters={true}
onTermChange={[MockFunction]}
page={0}
previousPage={[Function]}
@@ -46,7 +47,7 @@ exports[`components/admin_console/system_users/list should match default snapsho
"type": [Function],
}
}
search={[Function]}
search={[MockFunction]}
teamId=""
term=""
total={0}
@@ -214,6 +215,7 @@ exports[`components/admin_console/system_users/list should match default snapsho
isDisabled={false}
mfaEnabled={false}
nextPage={[Function]}
noBuiltInFilters={true}
onTermChange={[MockFunction]}
page={0}
previousPage={[Function]}
@@ -227,7 +229,7 @@ exports[`components/admin_console/system_users/list should match default snapsho
"type": [Function],
}
}
search={[Function]}
search={[MockFunction]}
teamId=""
term=""
total={0}
@@ -441,6 +443,7 @@ exports[`components/admin_console/system_users/list should match default snapsho
isDisabled={false}
mfaEnabled={true}
nextPage={[Function]}
noBuiltInFilters={true}
onTermChange={[MockFunction]}
page={0}
previousPage={[Function]}
@@ -454,7 +457,7 @@ exports[`components/admin_console/system_users/list should match default snapsho
"type": [Function],
}
}
search={[Function]}
search={[MockFunction]}
teamId=""
term=""
total={0}

View File

@@ -6,7 +6,7 @@ import type {UserProfile} from '@mattermost/types/users';
import * as users from 'mattermost-redux/selectors/entities/users';
import {getUsers} from 'components/admin_console/system_users/list/selectors';
import {getUsers} from 'components/admin_console/system_users/system_users_list/selectors';
jest.mock('mattermost-redux/selectors/entities/users');

View File

@@ -6,7 +6,7 @@ import React from 'react';
import type {UserProfile} from '@mattermost/types/users';
import SystemUsersList from 'components/admin_console/system_users/list/system_users_list';
import SystemUsersList from 'components/admin_console/system_users/system_users_list/system_users_list';
import {Constants} from 'utils/constants';

View File

@@ -31,7 +31,6 @@ type Props = {
nextPage: (page: number) => void;
search: (term: string) => void;
focusOnMount?: boolean;
renderFilterRow: (doSearch: ((event: React.FormEvent<HTMLInputElement>) => void) | undefined) => JSX.Element;
teamId: string;
filter: string;
@@ -347,11 +346,9 @@ export default class SystemUsersList extends React.PureComponent<Props, State> {
}}
nextPage={this.nextPage}
previousPage={this.previousPage}
search={this.search}
page={this.state.page}
term={this.props.term}
onTermChange={this.props.onTermChange}
rowComponentType={UserListRowWithError}
noBuiltInFilters={true}
/>
<ManageTeamsModal
user={this.state.user}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import debounce from 'lodash/debounce';
import type {ChangeEvent} from 'react';
import React, {useCallback, useEffect} from 'react';
import {useIntl} from 'react-intl';
import Constants from 'utils/constants';
type Props = {
value?: string;
onChange: ({searchTerm, teamId, filter}: {searchTerm?: string; teamId?: string; filter?: string}) => void;
onSearch: (value: string) => void;
};
function SystemUsersSearch(props: Props) {
const {formatMessage} = useIntl();
const debouncedSearch = useCallback(debounce((value: string) => {
props.onSearch(value);
}, Constants.SEARCH_TIMEOUT_MILLISECONDS), []);
useEffect(() => {
return () => {
debouncedSearch.cancel();
};
}, []);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
const searchTerm = e?.target?.value?.trim() ?? '';
props.onChange({searchTerm});
if (searchTerm.length > 0) {
debouncedSearch(searchTerm);
}
}
return (
<div className='system-users__filter'>
<input
id='searchUsers'
className='form-control filter-textbox'
placeholder={formatMessage({
id: 'filtered_user_list.search',
defaultMessage: 'Search users',
})}
value={props.value}
onChange={handleChange}
/>
</div>
);
}
export default SystemUsersSearch;

View File

@@ -24,6 +24,7 @@ type Props = {
previousPage: () => void;
search: (term: string) => void;
actions?: React.ReactNode[];
noBuiltInFilters?: boolean;
actionProps?: {
mfaEnabled: boolean;
enableUserAccessTokens: boolean;
@@ -274,31 +275,33 @@ class SearchableUserList extends React.PureComponent<Props, State> {
}
let filterRow;
if (this.props.renderFilterRow) {
filterRow = this.props.renderFilterRow(this.handleInput);
} else {
filterRow = (
<div className='col-xs-12'>
<label
className='hidden-label'
htmlFor='searchUsersInput'
>
<FormattedMessage
id='filtered_user_list.search'
defaultMessage='Search users'
if (!this.props.noBuiltInFilters) {
if (this.props.renderFilterRow) {
filterRow = this.props.renderFilterRow(this.handleInput);
} else {
filterRow = (
<div className='col-xs-12'>
<label
className='hidden-label'
htmlFor='searchUsersInput'
>
<FormattedMessage
id='filtered_user_list.search'
defaultMessage='Search users'
/>
</label>
<QuickInput
ref={this.filterRef}
id='searchUsersInput'
className='form-control filter-textbox'
placeholder={this.props.intl.formatMessage({id: 'filtered_user_list.search', defaultMessage: 'Search users'})}
aria-label={this.props.intl.formatMessage({id: 'filtered_user_list.search', defaultMessage: 'Search users'})}
onInput={this.handleInput}
value={this.props.term}
/>
</label>
<QuickInput
ref={this.filterRef}
id='searchUsersInput'
className='form-control filter-textbox'
placeholder={this.props.intl.formatMessage({id: 'filtered_user_list.search', defaultMessage: 'Search users'})}
aria-label={this.props.intl.formatMessage({id: 'filtered_user_list.search', defaultMessage: 'Search users'})}
onInput={this.handleInput}
value={this.props.term}
/>
</div>
);
</div>
);
}
}
return (