mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
e39410c094
commit
ea8d9d77f4
@ -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,45 +59,58 @@ 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
|
||||||
<FilterInput
|
options={[
|
||||||
placeholder="Search user by login, email, or name."
|
{ label: 'All users', value: 'all' },
|
||||||
autoFocus={true}
|
{ label: 'Active last 30 days', value: 'activeLast30Days' },
|
||||||
value={query}
|
]}
|
||||||
onChange={(value) => changeQuery(value)}
|
onChange={changeFilter}
|
||||||
/>
|
value={filter}
|
||||||
|
className={styles.filter}
|
||||||
|
/>
|
||||||
|
<FilterInput
|
||||||
|
placeholder="Search user by login, email, or name."
|
||||||
|
autoFocus={true}
|
||||||
|
value={query}
|
||||||
|
onChange={changeQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
|
||||||
|
<LinkButton href="admin/users/create" variant="primary">
|
||||||
|
New user
|
||||||
|
</LinkButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<PageLoader />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={cx(styles.table, 'admin-list-table')}>
|
||||||
|
<table className="filter-table form-inline filter-table--hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Login</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Server admin</th>
|
||||||
|
<th>
|
||||||
|
Last active
|
||||||
|
<Tooltip placement="top" content="Time since user was seen using Grafana">
|
||||||
|
<Icon name="question-circle" />
|
||||||
|
</Tooltip>
|
||||||
|
</th>
|
||||||
|
<th style={{ width: '1%' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{users.map(renderUser)}</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{contextSrv.hasPermission(AccessControlAction.UsersCreate) && (
|
{showPaging && <Pagination numberOfPages={totalPages} currentPage={page} onNavigate={changePage} />}
|
||||||
<LinkButton href="admin/users/create" variant="primary">
|
</>
|
||||||
New user
|
)}
|
||||||
</LinkButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={cx(styles.table, 'admin-list-table')}>
|
|
||||||
<table className="filter-table form-inline filter-table--hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th>Login</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>
|
|
||||||
Seen
|
|
||||||
<Tooltip placement="top" content="Time since user was seen using Grafana">
|
|
||||||
<Icon name="question-circle" />
|
|
||||||
</Tooltip>
|
|
||||||
</th>
|
|
||||||
<th></th>
|
|
||||||
<th style={{ width: '1%' }}></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>{users.map(renderUser)}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{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…
Reference in New Issue
Block a user