Admin: Use backend sort (#75228)

* Admin: Use InteractiveTable

* Admin: Fix pagination

* Admin: Use CellWrapper

* Admin: Split components

* Admin: Separate OrgUsersTable

* Admin: Remove UsersTable

* Admin: Use the new table for TeamList

* Admin: Cleanup TeamList page

* Admin: Add edit team action

* Admin: Use explicit edit action instead of a link wrapper

* Admin: Fix responsive styles

* Cleanup

* Remove redundant sort

* Add item key

* Fix icon styles

* Set loading by default

* Use separate pagination component

* Use default sorting functionality

* Fix merge conflicts

* Update betterer

* Add controlled sort

* Revert pagination changes

* Move pagination inside OrgUsersTable.tsx

* Add missing prop

* Sort users table

* Fix tests

* Remove loader

* Add sort to Teams

* Cleanup

* Update loadTeams action

* Remove sort condition
This commit is contained in:
Alex Khomenko 2023-09-29 11:48:36 +02:00 committed by GitHub
parent 5796836662
commit 4a692fc82e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 151 additions and 68 deletions

View File

@ -1,5 +1,5 @@
// BETTERER RESULTS V2.
//
//
// If this file contains merge conflicts, use `betterer merge` to automatically resolve them:
// https://phenomnomnominal.github.io/betterer/docs/results-file/#merge
//

View File

@ -8,11 +8,10 @@ import { LinkButton, RadioButtonGroup, useStyles2, FilterInput } from '@grafana/
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import PageLoader from '../../core/components/PageLoader/PageLoader';
import { AccessControlAction, StoreState, UserFilter } from '../../types';
import { UsersTable } from './Users/UsersTable';
import { changeFilter, changePage, changeQuery, fetchUsers } from './state/actions';
import { changeFilter, changePage, changeQuery, changeSort, fetchUsers } from './state/actions';
export interface FilterProps {
filters: UserFilter[];
@ -31,6 +30,7 @@ const mapDispatchToProps = {
changeQuery,
changePage,
changeFilter,
changeSort,
};
const mapStateToProps = (state: StoreState) => ({
@ -40,7 +40,6 @@ const mapStateToProps = (state: StoreState) => ({
totalPages: state.userListAdmin.totalPages,
page: state.userListAdmin.page,
filters: state.userListAdmin.filters,
isLoading: state.userListAdmin.isLoading,
});
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -57,10 +56,10 @@ const UserListAdminPageUnConnected = ({
showPaging,
changeFilter,
filters,
isLoading,
totalPages,
page,
changePage,
changeSort,
}: Props) => {
const styles = useStyles2(getStyles);
@ -97,17 +96,14 @@ const UserListAdminPageUnConnected = ({
)}
</div>
</div>
{isLoading ? (
<PageLoader />
) : (
<UsersTable
users={users}
showPaging={showPaging}
totalPages={totalPages}
onChangePage={changePage}
currentPage={page}
/>
)}
<UsersTable
users={users}
showPaging={showPaging}
totalPages={totalPages}
onChangePage={changePage}
currentPage={page}
fetchData={changeSort}
/>
</Page.Contents>
);
};

View File

@ -13,6 +13,7 @@ import {
Tag,
InteractiveTable,
Column,
FetchDataFunc,
Pagination,
HorizontalGroup,
VerticalGroup,
@ -53,16 +54,24 @@ export interface Props {
orgId?: number;
onRoleChange: (role: OrgRole, user: OrgUser) => void;
onRemoveUser: (user: OrgUser) => void;
fetchData?: FetchDataFunc<OrgUser>;
changePage: (page: number) => void;
page: number;
totalPages: number;
}
export const OrgUsersTable = ({ users, orgId, onRoleChange, onRemoveUser, changePage, page, totalPages }: Props) => {
export const OrgUsersTable = ({
users,
orgId,
onRoleChange,
onRemoveUser,
fetchData,
changePage,
page,
totalPages,
}: Props) => {
const [userToRemove, setUserToRemove] = useState<OrgUser | null>(null);
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const enableSort = totalPages === 1;
const styles = useStyles2(getStyles);
useEffect(() => {
async function fetchOptions() {
@ -91,27 +100,25 @@ export const OrgUsersTable = ({ users, orgId, onRoleChange, onRemoveUser, change
id: 'login',
header: 'Login',
cell: ({ cell: { value } }: Cell<'login'>) => <div>{value}</div>,
sortType: enableSort ? 'string' : undefined,
sortType: 'string',
},
{
id: 'email',
header: 'Email',
cell: ({ cell: { value } }: Cell<'email'>) => value,
sortType: enableSort ? 'string' : undefined,
sortType: 'string',
},
{
id: 'name',
header: 'Name',
cell: ({ cell: { value } }: Cell<'name'>) => value,
sortType: enableSort ? 'string' : undefined,
sortType: 'string',
},
{
id: 'lastSeenAtAge',
header: 'Last active',
cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => value,
sortType: enableSort
? (a, b) => new Date(a.original.lastSeenAt).getTime() - new Date(b.original.lastSeenAt).getTime()
: undefined,
sortType: (a, b) => new Date(a.original.lastSeenAt).getTime() - new Date(b.original.lastSeenAt).getTime(),
},
{
id: 'role',
@ -175,17 +182,15 @@ export const OrgUsersTable = ({ users, orgId, onRoleChange, onRemoveUser, change
},
},
],
[orgId, roleOptions, onRoleChange, enableSort]
[orgId, roleOptions, onRoleChange]
);
return (
<VerticalGroup spacing="md" data-testid={selectors.container}>
<div className={styles.wrapper}>
<InteractiveTable columns={columns} data={users} getRowId={(user) => String(user.userId)} />
<HorizontalGroup justify="flex-end" height={'auto'}>
<Pagination onNavigate={changePage} currentPage={page} numberOfPages={totalPages} hideWhenSinglePage={true} />
</HorizontalGroup>
</div>
<InteractiveTable columns={columns} data={users} getRowId={(user) => String(user.userId)} fetchData={fetchData} />
<HorizontalGroup justify="flex-end">
<Pagination onNavigate={changePage} currentPage={page} numberOfPages={totalPages} hideWhenSinglePage={true} />
</HorizontalGroup>
{Boolean(userToRemove) && (
<ConfirmModal
body={`Are you sure you want to delete user ${userToRemove?.login}?`}

View File

@ -13,6 +13,7 @@ import {
Column,
VerticalGroup,
HorizontalGroup,
FetchDataFunc,
} from '@grafana/ui';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { UserDTO } from 'app/types';
@ -28,11 +29,18 @@ interface UsersTableProps {
totalPages: number;
onChangePage: (page: number) => void;
currentPage: number;
fetchData?: FetchDataFunc<UserDTO>;
}
export const UsersTable = ({ users, showPaging, totalPages, onChangePage, currentPage }: UsersTableProps) => {
export const UsersTable = ({
users,
showPaging,
totalPages,
onChangePage,
currentPage,
fetchData,
}: UsersTableProps) => {
const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]);
const enableSort = totalPages === 1;
const columns: Array<Column<UserDTO>> = useMemo(
() => [
{
@ -44,25 +52,25 @@ export const UsersTable = ({ users, showPaging, totalPages, onChangePage, curren
id: 'login',
header: 'Login',
cell: ({ cell: { value } }: Cell<'login'>) => value,
sortType: enableSort ? 'string' : undefined,
sortType: 'string',
},
{
id: 'email',
header: 'Email',
cell: ({ cell: { value } }: Cell<'email'>) => value,
sortType: enableSort ? 'string' : undefined,
sortType: 'string',
},
{
id: 'name',
header: 'Name',
cell: ({ cell: { value } }: Cell<'name'>) => value,
sortType: enableSort ? 'string' : undefined,
sortType: 'string',
},
{
id: 'orgs',
header: 'Belongs to',
cell: OrgUnitsCell,
sortType: enableSort ? (a, b) => (a.original.orgs?.length || 0) - (b.original.orgs?.length || 0) : undefined,
sortType: (a, b) => (a.original.orgs?.length || 0) - (b.original.orgs?.length || 0),
},
...(showLicensedRole
? [
@ -71,7 +79,7 @@ export const UsersTable = ({ users, showPaging, totalPages, onChangePage, curren
header: 'Licensed role',
cell: LicensedRoleCell,
// Needs the assertion here, the types are not inferred correctly due to the conditional assignment
sortType: enableSort ? ('string' as const) : undefined,
sortType: 'string' as const,
},
]
: []),
@ -83,9 +91,7 @@ export const UsersTable = ({ users, showPaging, totalPages, onChangePage, curren
iconName: 'question-circle',
},
cell: LastSeenAtCell,
sortType: enableSort
? (a, b) => new Date(a.original.lastSeenAt!).getTime() - new Date(b.original.lastSeenAt!).getTime()
: undefined,
sortType: (a, b) => new Date(a.original.lastSeenAt!).getTime() - new Date(b.original.lastSeenAt!).getTime(),
},
{
id: 'authLabels',
@ -113,11 +119,11 @@ export const UsersTable = ({ users, showPaging, totalPages, onChangePage, curren
},
},
],
[showLicensedRole, enableSort]
[showLicensedRole]
);
return (
<VerticalGroup spacing={'md'}>
<InteractiveTable columns={columns} data={users} getRowId={(user) => String(user.id)} />
<InteractiveTable columns={columns} data={users} getRowId={(user) => String(user.id)} fetchData={fetchData} />
{showPaging && (
<HorizontalGroup justify={'flex-end'}>
<Pagination numberOfPages={totalPages} currentPage={currentPage} onNavigate={onChangePage} />

View File

@ -2,6 +2,7 @@ import { debounce } from 'lodash';
import { dateTimeFormatTimeAgo } from '@grafana/data';
import { featureEnabled, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
import { FetchDataArgs } from '@grafana/ui';
import config from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
@ -26,6 +27,7 @@ import {
filterChanged,
usersFetchBegin,
usersFetchEnd,
sortChanged,
} from './reducers';
// UserAdminPage
@ -281,10 +283,12 @@ const getFilters = (filters: UserFilter[]) => {
export function fetchUsers(): ThunkResult<void> {
return async (dispatch, getState) => {
try {
const { perPage, page, query, filters } = getState().userListAdmin;
const result = await getBackendSrv().get(
`/api/users/search?perpage=${perPage}&page=${page}&query=${query}&${getFilters(filters)}`
);
const { perPage, page, query, filters, sort } = getState().userListAdmin;
let url = `/api/users/search?perpage=${perPage}&page=${page}&query=${query}&${getFilters(filters)}`;
if (sort) {
url += `&sort=${sort}`;
}
const result = await getBackendSrv().get(url);
dispatch(usersFetched(result));
} catch (error) {
usersFetchEnd();
@ -318,3 +322,15 @@ export function changePage(page: number): ThunkResult<void> {
dispatch(fetchUsers());
};
}
export function changeSort({ sortBy }: FetchDataArgs<UserDTO>): ThunkResult<void> {
const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined;
return async (dispatch, getState) => {
const currentSort = getState().userListAdmin.sort;
if (currentSort !== sort) {
dispatch(usersFetchBegin());
dispatch(sortChanged(sort));
dispatch(fetchUsers());
}
};
}

View File

@ -173,6 +173,11 @@ export const userListAdminSlice = createSlice({
...state,
page: action.payload,
}),
sortChanged: (state, action: PayloadAction<UserListAdminState['sort']>) => ({
...state,
page: 0,
sort: action.payload,
}),
filterChanged: (state, action: PayloadAction<UserFilter>) => {
const { name, value } = action.payload;
@ -192,7 +197,7 @@ export const userListAdminSlice = createSlice({
},
});
export const { usersFetched, usersFetchBegin, usersFetchEnd, queryChanged, pageChanged, filterChanged } =
export const { usersFetched, usersFetchBegin, usersFetchEnd, queryChanged, pageChanged, filterChanged, sortChanged } =
userListAdminSlice.actions;
export const userListAdminReducer = userListAdminSlice.reducer;

View File

@ -25,6 +25,7 @@ const setup = (propOverrides?: object) => {
deleteTeam: jest.fn(),
changePage: jest.fn(),
changeQuery: jest.fn(),
changeSort: jest.fn(),
query: '',
totalPages: 0,
page: 0,

View File

@ -25,7 +25,7 @@ import { AccessControlAction, Role, StoreState, Team } from 'app/types';
import { TeamRolePicker } from '../../core/components/RolePicker/TeamRolePicker';
import { Avatar } from '../admin/Users/Avatar';
import { deleteTeam, loadTeams, changePage, changeQuery } from './state/actions';
import { deleteTeam, loadTeams, changePage, changeQuery, changeSort } from './state/actions';
import { isPermissionTeamAdmin } from './state/selectors';
type Cell<T extends keyof Team = keyof Team> = CellProps<Team, Team[T]>;
@ -48,9 +48,9 @@ export const TeamList = ({
editorsCanAdmin,
page,
changePage,
changeSort,
}: Props) => {
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const enableSort = totalPages === 1;
useEffect(() => {
loadTeams(true);
@ -76,19 +76,19 @@ export const TeamList = ({
id: 'name',
header: 'Name',
cell: ({ cell: { value } }: Cell<'name'>) => value,
sortType: enableSort ? 'string' : undefined,
sortType: 'string',
},
{
id: 'email',
header: 'Email',
cell: ({ cell: { value } }: Cell<'email'>) => value,
sortType: enableSort ? 'string' : undefined,
sortType: 'string',
},
{
id: 'memberCount',
header: 'Members',
cell: ({ cell: { value } }: Cell<'memberCount'>) => value,
sortType: enableSort ? 'number' : undefined,
sortType: 'number',
},
...(displayRolePicker
? [
@ -155,7 +155,7 @@ export const TeamList = ({
},
},
],
[displayRolePicker, editorsCanAdmin, roleOptions, signedInUser, deleteTeam, enableSort]
[displayRolePicker, editorsCanAdmin, roleOptions, signedInUser, deleteTeam]
);
return (
@ -185,7 +185,12 @@ export const TeamList = ({
</LinkButton>
</div>
<VerticalGroup spacing={'md'}>
<InteractiveTable columns={columns} data={teams} getRowId={(team) => String(team.id)} />
<InteractiveTable
columns={columns}
data={teams}
getRowId={(team) => String(team.id)}
fetchData={changeSort}
/>
<HorizontalGroup justify="flex-end">
<Pagination hideWhenSinglePage currentPage={page} numberOfPages={totalPages} onNavigate={changePage} />
</HorizontalGroup>
@ -224,6 +229,7 @@ const mapDispatchToProps = {
deleteTeam,
changePage,
changeQuery,
changeSort,
};
const connector = connect(mapStateToProps, mapDispatchToProps);

View File

@ -1,17 +1,26 @@
import { debounce } from 'lodash';
import { getBackendSrv } from '@grafana/runtime';
import { FetchDataArgs } from '@grafana/ui';
import { updateNavIndex } from 'app/core/actions';
import { contextSrv } from 'app/core/core';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { AccessControlAction, TeamMember, ThunkResult } from 'app/types';
import { AccessControlAction, Team, TeamMember, ThunkResult } from 'app/types';
import { buildNavModel } from './navModel';
import { teamGroupsLoaded, queryChanged, pageChanged, teamLoaded, teamMembersLoaded, teamsLoaded } from './reducers';
import {
teamGroupsLoaded,
queryChanged,
pageChanged,
teamLoaded,
teamMembersLoaded,
teamsLoaded,
sortChanged,
} from './reducers';
export function loadTeams(initial = false): ThunkResult<void> {
return async (dispatch, getState) => {
const { query, page, perPage } = getState().teams;
const { query, page, perPage, sort } = getState().teams;
// Early return if the user cannot list teams
if (!contextSrv.hasPermission(AccessControlAction.ActionTeamsRead)) {
dispatch(teamsLoaded({ teams: [], totalCount: 0, page: 1, perPage, noTeams: true }));
@ -20,7 +29,7 @@ export function loadTeams(initial = false): ThunkResult<void> {
const response = await getBackendSrv().get(
'/api/teams/search',
accessControlQueryParam({ query, page, perpage: perPage })
accessControlQueryParam({ query, page, perpage: perPage, sort })
);
// We only want to check if there is no teams on the initial request.
@ -67,6 +76,14 @@ export function changePage(page: number): ThunkResult<void> {
};
}
export function changeSort({ sortBy }: FetchDataArgs<Team>): ThunkResult<void> {
const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined;
return async (dispatch) => {
dispatch(sortChanged(sort));
dispatch(loadTeams());
};
}
export function loadTeamMembers(): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;

View File

@ -35,10 +35,13 @@ const teamsSlice = createSlice({
pageChanged: (state, action: PayloadAction<number>): TeamsState => {
return { ...state, page: action.payload };
},
sortChanged: (state, action: PayloadAction<TeamsState['sort']>): TeamsState => {
return { ...state, sort: action.payload, page: 1 };
},
},
});
export const { teamsLoaded, queryChanged, pageChanged } = teamsSlice.actions;
export const { teamsLoaded, queryChanged, pageChanged, sortChanged } = teamsSlice.actions;
export const teamsReducer = teamsSlice.reducer;

View File

@ -7,7 +7,7 @@ import { configureStore } from 'app/store/configureStore';
import { Invitee, OrgUser } from 'app/types';
import { Props, UsersListPageUnconnected } from './UsersListPage';
import { pageChanged } from './state/reducers';
import { pageChanged, sortChanged } from './state/reducers';
jest.mock('../../core/app_events', () => ({
emit: jest.fn(),
@ -36,6 +36,7 @@ const setup = (propOverrides?: object) => {
updateUser: jest.fn(),
removeUser: jest.fn(),
changePage: mockToolkitActionCreator(pageChanged),
changeSort: mockToolkitActionCreator(sortChanged),
isLoading: false,
};

View File

@ -12,7 +12,7 @@ import { fetchInvitees } from '../invites/state/actions';
import { selectInvitesMatchingQuery } from '../invites/state/selectors';
import { UsersActionBar } from './UsersActionBar';
import { loadUsers, removeUser, updateUser, changePage } from './state/actions';
import { loadUsers, removeUser, updateUser, changePage, changeSort } from './state/actions';
import { getUsers, getUsersSearchQuery } from './state/selectors';
function mapStateToProps(state: StoreState) {
@ -33,6 +33,7 @@ const mapDispatchToProps = {
loadUsers,
fetchInvitees,
changePage,
changeSort,
updateUser,
removeUser,
};
@ -57,6 +58,7 @@ export const UsersListPageUnconnected = ({
changePage,
updateUser,
removeUser,
changeSort,
}: Props) => {
const [showInvites, setShowInvites] = useState(false);
const externalUserMngInfoHtml = externalUserMngInfo ? renderMarkdown(externalUserMngInfo) : '';
@ -86,6 +88,7 @@ export const UsersListPageUnconnected = ({
orgId={contextSrv.user.orgId}
onRoleChange={onRoleChange}
onRemoveUser={onRemoveUser}
fetchData={changeSort}
changePage={changePage}
page={page}
totalPages={totalPages}

View File

@ -1,20 +1,21 @@
import { debounce } from 'lodash';
import { getBackendSrv } from '@grafana/runtime';
import { FetchDataArgs } from '@grafana/ui';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { OrgUser } from 'app/types';
import { ThunkResult } from '../../../types';
import { usersLoaded, pageChanged, usersFetchBegin, usersFetchEnd, searchQueryChanged } from './reducers';
import { usersLoaded, pageChanged, usersFetchBegin, usersFetchEnd, searchQueryChanged, sortChanged } from './reducers';
export function loadUsers(): ThunkResult<void> {
return async (dispatch, getState) => {
try {
const { perPage, page, searchQuery } = getState().users;
const { perPage, page, searchQuery, sort } = getState().users;
const users = await getBackendSrv().get(
`/api/org/users/search`,
accessControlQueryParam({ perpage: perPage, page, query: searchQuery })
accessControlQueryParam({ perpage: perPage, page, query: searchQuery, sort })
);
dispatch(usersLoaded(users));
} catch (error) {
@ -47,6 +48,15 @@ export function changePage(page: number): ThunkResult<void> {
};
}
export function changeSort({ sortBy }: FetchDataArgs<OrgUser>): ThunkResult<void> {
const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined;
return async (dispatch) => {
dispatch(usersFetchBegin());
dispatch(sortChanged(sort));
dispatch(loadUsers());
};
}
export function changeSearchQuery(query: string): ThunkResult<void> {
return async (dispatch) => {
dispatch(usersFetchBegin());

View File

@ -51,6 +51,10 @@ const usersSlice = createSlice({
...state,
page: action.payload,
}),
sortChanged: (state, action: PayloadAction<UsersState['sort']>) => ({
...state,
sort: action.payload,
}),
usersFetchBegin: (state) => {
return { ...state, isLoading: true };
},
@ -60,8 +64,15 @@ const usersSlice = createSlice({
},
});
export const { searchQueryChanged, setUsersSearchPage, usersLoaded, usersFetchBegin, usersFetchEnd, pageChanged } =
usersSlice.actions;
export const {
searchQueryChanged,
setUsersSearchPage,
usersLoaded,
usersFetchBegin,
usersFetchEnd,
pageChanged,
sortChanged,
} = usersSlice.actions;
export const usersReducer = usersSlice.reducer;

View File

@ -63,6 +63,7 @@ export interface TeamsState {
noTeams: boolean;
totalPages: number;
hasFetched: boolean;
sort?: string;
}
export interface TeamState {

View File

@ -80,6 +80,7 @@ export interface UsersState {
page: number;
perPage: number;
totalPages: number;
sort?: string;
}
export interface UserSession {
@ -124,4 +125,5 @@ export interface UserListAdminState {
showPaging: boolean;
filters: UserFilter[];
isLoading: boolean;
sort?: string;
}