Admin: User list tweaks (#38750)

* Setup filter

* Enable filtering users by active in last 30 days

* Add loading state

* Update last active age strings

* Tweak user list

* Use theme spacing

* Improve table's accessibility

* Add more aria-labels
pull/38547/head
Alex Khomenko 4 years ago committed by GitHub
parent e39410c094
commit ea8d9d77f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 39
      pkg/util/strings.go
  2. 16
      pkg/util/strings_test.go
  3. 84
      public/app/features/admin/UserListAdminPage.tsx
  4. 20
      public/app/features/admin/state/actions.ts
  5. 2
      public/app/features/admin/state/reducers.test.ts
  6. 22
      public/app/features/admin/state/reducers.ts
  7. 2
      public/app/types/user.ts

@ -48,24 +48,49 @@ func GetAgeString(t time.Time) string {
months := int(math.Floor(minutes / 43800)) months := int(math.Floor(minutes / 43800))
days := int(math.Floor(minutes / 1440)) days := int(math.Floor(minutes / 1440))
hours := int(math.Floor(minutes / 60)) hours := int(math.Floor(minutes / 60))
var amount string
if years > 0 { if years > 0 {
return fmt.Sprintf("%dy", years) if years == 1 {
amount = "year"
} else {
amount = "years"
}
return fmt.Sprintf("%d %s", years, amount)
} }
if months > 0 { if months > 0 {
return fmt.Sprintf("%dM", months) if months == 1 {
amount = "month"
} else {
amount = "months"
}
return fmt.Sprintf("%d %s", months, amount)
} }
if days > 0 { if days > 0 {
return fmt.Sprintf("%dd", days) if days == 1 {
amount = "day"
} else {
amount = "days"
}
return fmt.Sprintf("%d %s", days, amount)
} }
if hours > 0 { if hours > 0 {
return fmt.Sprintf("%dh", hours) if hours == 1 {
amount = "hour"
} else {
amount = "hours"
}
return fmt.Sprintf("%d %s", hours, amount)
} }
if int(minutes) > 0 { if int(minutes) > 0 {
return fmt.Sprintf("%dm", int(minutes)) if int(minutes) == 1 {
amount = "minute"
} else {
amount = "minutes"
}
return fmt.Sprintf("%d %s", int(minutes), amount)
} }
return "< 1m" return "< 1 minute"
} }
// ToCamelCase changes kebab case, snake case or mixed strings to camel case. See unit test for examples. // ToCamelCase changes kebab case, snake case or mixed strings to camel case. See unit test for examples.

@ -62,14 +62,14 @@ func TestDateAge(t *testing.T) {
assert.Equal(t, "?", GetAgeString(time.Time{})) // base case assert.Equal(t, "?", GetAgeString(time.Time{})) // base case
tests := map[time.Duration]string{ tests := map[time.Duration]string{
-1 * time.Hour: "< 1m", // one hour in the future -1 * time.Hour: "< 1 minute", // one hour in the future
0: "< 1m", 0: "< 1 minute",
2 * time.Second: "< 1m", 2 * time.Second: "< 1 minute",
2 * time.Minute: "2m", 2 * time.Minute: "2 minutes",
2 * time.Hour: "2h", 2 * time.Hour: "2 hours",
3 * 24 * time.Hour: "3d", 3 * 24 * time.Hour: "3 days",
67 * 24 * time.Hour: "2M", 67 * 24 * time.Hour: "2 months",
409 * 24 * time.Hour: "1y", 409 * 24 * time.Hour: "1 year",
} }
for elapsed, expected := range tests { for elapsed, expected := range tests {
assert.Equalf( assert.Equalf(

@ -1,19 +1,22 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { Pagination, Tooltip, stylesFactory, LinkButton, Icon } from '@grafana/ui'; import { Pagination, Tooltip, LinkButton, Icon, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { AccessControlAction, StoreState, UserDTO } from '../../types'; import { GrafanaTheme2 } from '@grafana/data';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { getNavModel } from '../../core/selectors/navModel';
import { fetchUsers, changeQuery, changePage } from './state/actions';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { getNavModel } from '../../core/selectors/navModel';
import { AccessControlAction, StoreState, UserDTO } from '../../types';
import { fetchUsers, changeQuery, changePage, changeFilter } from './state/actions';
import PageLoader from '../../core/components/PageLoader/PageLoader';
const mapDispatchToProps = { const mapDispatchToProps = {
fetchUsers, fetchUsers,
changeQuery, changeQuery,
changePage, changePage,
changeFilter,
}; };
const mapStateToProps = (state: StoreState) => ({ const mapStateToProps = (state: StoreState) => ({
@ -23,6 +26,8 @@ const mapStateToProps = (state: StoreState) => ({
showPaging: state.userListAdmin.showPaging, showPaging: state.userListAdmin.showPaging,
totalPages: state.userListAdmin.totalPages, totalPages: state.userListAdmin.totalPages,
page: state.userListAdmin.page, page: state.userListAdmin.page,
filter: state.userListAdmin.filter,
isLoading: state.userListAdmin.isLoading,
}); });
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
@ -31,9 +36,21 @@ interface OwnProps {}
type Props = OwnProps & ConnectedProps<typeof connector>; type Props = OwnProps & ConnectedProps<typeof connector>;
const UserListAdminPageUnConnected: React.FC<Props> = (props) => { const UserListAdminPageUnConnected: React.FC<Props> = ({
const styles = getStyles(); fetchUsers,
const { fetchUsers, navModel, query, changeQuery, users, showPaging, totalPages, page, changePage } = props; navModel,
query,
changeQuery,
users,
showPaging,
totalPages,
page,
changePage,
changeFilter,
filter,
isLoading,
}) => {
const styles = useStyles2(getStyles);
useEffect(() => { useEffect(() => {
fetchUsers(); fetchUsers();
@ -42,14 +59,22 @@ const UserListAdminPageUnConnected: React.FC<Props> = (props) => {
return ( return (
<Page navModel={navModel}> <Page navModel={navModel}>
<Page.Contents> <Page.Contents>
<>
<div className="page-action-bar"> <div className="page-action-bar">
<div className="gf-form gf-form--grow"> <div className="gf-form gf-form--grow">
<RadioButtonGroup
options={[
{ label: 'All users', value: 'all' },
{ label: 'Active last 30 days', value: 'activeLast30Days' },
]}
onChange={changeFilter}
value={filter}
className={styles.filter}
/>
<FilterInput <FilterInput
placeholder="Search user by login, email, or name." placeholder="Search user by login, email, or name."
autoFocus={true} autoFocus={true}
value={query} value={query}
onChange={(value) => changeQuery(value)} onChange={changeQuery}
/> />
</div> </div>
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( {contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
@ -58,6 +83,10 @@ const UserListAdminPageUnConnected: React.FC<Props> = (props) => {
</LinkButton> </LinkButton>
)} )}
</div> </div>
{isLoading ? (
<PageLoader />
) : (
<>
<div className={cx(styles.table, 'admin-list-table')}> <div className={cx(styles.table, 'admin-list-table')}>
<table className="filter-table form-inline filter-table--hover"> <table className="filter-table form-inline filter-table--hover">
<thead> <thead>
@ -66,13 +95,13 @@ const UserListAdminPageUnConnected: React.FC<Props> = (props) => {
<th>Login</th> <th>Login</th>
<th>Email</th> <th>Email</th>
<th>Name</th> <th>Name</th>
<th>Server admin</th>
<th> <th>
Seen&nbsp; Last active&nbsp;
<Tooltip placement="top" content="Time since user was seen using Grafana"> <Tooltip placement="top" content="Time since user was seen using Grafana">
<Icon name="question-circle" /> <Icon name="question-circle" />
</Tooltip> </Tooltip>
</th> </th>
<th></th>
<th style={{ width: '1%' }}></th> <th style={{ width: '1%' }}></th>
</tr> </tr>
</thead> </thead>
@ -81,6 +110,7 @@ const UserListAdminPageUnConnected: React.FC<Props> = (props) => {
</div> </div>
{showPaging && <Pagination numberOfPages={totalPages} currentPage={page} onNavigate={changePage} />} {showPaging && <Pagination numberOfPages={totalPages} currentPage={page} onNavigate={changePage} />}
</> </>
)}
</Page.Contents> </Page.Contents>
</Page> </Page>
); );
@ -92,35 +122,44 @@ const renderUser = (user: UserDTO) => {
return ( return (
<tr key={user.id}> <tr key={user.id}>
<td className="width-4 text-center link-td"> <td className="width-4 text-center link-td">
<a href={editUrl}> <a href={editUrl} aria-label={`Edit user's ${user.name} details`}>
<img className="filter-table__avatar" src={user.avatarUrl} /> <img className="filter-table__avatar" src={user.avatarUrl} alt={`Avatar for user ${user.name}`} />
</a> </a>
</td> </td>
<td className="link-td max-width-10"> <td className="link-td max-width-10">
<a className="ellipsis" href={editUrl} title={user.login}> <a className="ellipsis" href={editUrl} title={user.login} aria-label={`Edit user's ${user.name} details`}>
{user.login} {user.login}
</a> </a>
</td> </td>
<td className="link-td max-width-10"> <td className="link-td max-width-10">
<a className="ellipsis" href={editUrl} title={user.email}> <a className="ellipsis" href={editUrl} title={user.email} aria-label={`Edit user's ${user.name} details`}>
{user.email} {user.email}
</a> </a>
</td> </td>
<td className="link-td max-width-10"> <td className="link-td max-width-10">
<a className="ellipsis" href={editUrl} title={user.name}> <a className="ellipsis" href={editUrl} title={user.name} aria-label={`Edit user's ${user.name} details`}>
{user.name} {user.name}
</a> </a>
</td> </td>
<td className="link-td">{user.lastSeenAtAge && <a href={editUrl}>{user.lastSeenAtAge}</a>}</td>
<td className="link-td"> <td className="link-td">
{user.isAdmin && ( {user.isAdmin && (
<a href={editUrl}> <a href={editUrl} aria-label={`Edit user's ${user.name} details`}>
<Tooltip placement="top" content="Grafana Admin"> <Tooltip placement="top" content="Grafana Admin">
<Icon name="shield" /> <Icon name="shield" />
</Tooltip> </Tooltip>
</a> </a>
)} )}
</td> </td>
<td className="link-td">
{user.lastSeenAtAge && (
<a
href={editUrl}
aria-label={`Last seen at ${user.lastSeenAtAge}. Follow to edit user's ${user.name} details.`}
>
{user.lastSeenAtAge}
</a>
)}
</td>
<td className="text-right"> <td className="text-right">
{Array.isArray(user.authLabels) && user.authLabels.length > 0 && ( {Array.isArray(user.authLabels) && user.authLabels.length > 0 && (
<TagBadge label={user.authLabels[0]} removeIcon={false} count={0} /> <TagBadge label={user.authLabels[0]} removeIcon={false} count={0} />
@ -133,12 +172,15 @@ const renderUser = (user: UserDTO) => {
); );
}; };
const getStyles = stylesFactory(() => { const getStyles = (theme: GrafanaTheme2) => {
return { return {
table: css` table: css`
margin-top: 28px; margin-top: ${theme.spacing(3)};
`,
filter: css`
margin-right: ${theme.spacing(1)};
`, `,
}; };
}); };
export default connector(UserListAdminPageUnConnected); export default connector(UserListAdminPageUnConnected);

@ -19,6 +19,9 @@ import {
usersFetched, usersFetched,
queryChanged, queryChanged,
pageChanged, pageChanged,
filterChanged,
usersFetchBegin,
usersFetchEnd,
} from './reducers'; } from './reducers';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
@ -258,10 +261,13 @@ export function clearUserMappingInfo(): ThunkResult<void> {
export function fetchUsers(): ThunkResult<void> { export function fetchUsers(): ThunkResult<void> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
try { try {
const { perPage, page, query } = getState().userListAdmin; const { perPage, page, query, filter } = getState().userListAdmin;
const result = await getBackendSrv().get(`/api/users/search?perpage=${perPage}&page=${page}&query=${query}`); const result = await getBackendSrv().get(
`/api/users/search?perpage=${perPage}&page=${page}&query=${query}&filter=${filter}`
);
dispatch(usersFetched(result)); dispatch(usersFetched(result));
} catch (error) { } catch (error) {
usersFetchEnd();
console.error(error); console.error(error);
} }
}; };
@ -271,13 +277,23 @@ const fetchUsersWithDebounce = debounce((dispatch) => dispatch(fetchUsers()), 50
export function changeQuery(query: string): ThunkResult<void> { export function changeQuery(query: string): ThunkResult<void> {
return async (dispatch) => { return async (dispatch) => {
dispatch(usersFetchBegin());
dispatch(queryChanged(query)); dispatch(queryChanged(query));
fetchUsersWithDebounce(dispatch); fetchUsersWithDebounce(dispatch);
}; };
} }
export function changeFilter(filter: string): ThunkResult<void> {
return async (dispatch) => {
dispatch(usersFetchBegin());
dispatch(filterChanged(filter));
fetchUsersWithDebounce(dispatch);
};
}
export function changePage(page: number): ThunkResult<void> { export function changePage(page: number): ThunkResult<void> {
return async (dispatch) => { return async (dispatch) => {
dispatch(usersFetchBegin());
dispatch(pageChanged(page)); dispatch(pageChanged(page));
dispatch(fetchUsers()); dispatch(fetchUsers());
}; };

@ -32,6 +32,8 @@ const makeInitialUserListAdminState = (): UserListAdminState => ({
perPage: 50, perPage: 50,
totalPages: 1, totalPages: 1,
showPaging: false, showPaging: false,
filter: 'all',
isLoading: false,
}); });
const getTestUserMapping = (): LdapUser => ({ const getTestUserMapping = (): LdapUser => ({

@ -128,6 +128,8 @@ const initialUserListAdminState: UserListAdminState = {
perPage: 50, perPage: 50,
totalPages: 1, totalPages: 1,
showPaging: false, showPaging: false,
filter: 'all',
isLoading: false,
}; };
interface UsersFetched { interface UsersFetched {
@ -151,8 +153,15 @@ export const userListAdminSlice = createSlice({
totalPages, totalPages,
perPage, perPage,
showPaging: totalPages > 1, showPaging: totalPages > 1,
isLoading: false,
}; };
}, },
usersFetchBegin: (state) => {
return { ...state, isLoading: true };
},
usersFetchEnd: (state) => {
return { ...state, isLoading: false };
},
queryChanged: (state, action: PayloadAction<string>) => ({ queryChanged: (state, action: PayloadAction<string>) => ({
...state, ...state,
query: action.payload, query: action.payload,
@ -162,10 +171,21 @@ export const userListAdminSlice = createSlice({
...state, ...state,
page: action.payload, page: action.payload,
}), }),
filterChanged: (state, action: PayloadAction<string>) => ({
...state,
filter: action.payload,
}),
}, },
}); });
export const { usersFetched, queryChanged, pageChanged } = userListAdminSlice.actions; export const {
usersFetched,
usersFetchBegin,
usersFetchEnd,
queryChanged,
pageChanged,
filterChanged,
} = userListAdminSlice.actions;
export const userListAdminReducer = userListAdminSlice.reducer; export const userListAdminReducer = userListAdminSlice.reducer;
export default { export default {

@ -107,4 +107,6 @@ export interface UserListAdminState {
page: number; page: number;
totalPages: number; totalPages: number;
showPaging: boolean; showPaging: boolean;
filter: string;
isLoading: boolean;
} }

Loading…
Cancel
Save