diff --git a/pkg/util/strings.go b/pkg/util/strings.go index 9ce5d03e126..fd2509c1b70 100644 --- a/pkg/util/strings.go +++ b/pkg/util/strings.go @@ -48,24 +48,49 @@ func GetAgeString(t time.Time) string { months := int(math.Floor(minutes / 43800)) days := int(math.Floor(minutes / 1440)) hours := int(math.Floor(minutes / 60)) - + var amount string 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 { - return fmt.Sprintf("%dM", months) + if months == 1 { + amount = "month" + } else { + amount = "months" + } + return fmt.Sprintf("%d %s", months, amount) } 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 { - return fmt.Sprintf("%dh", hours) + if hours == 1 { + amount = "hour" + } else { + amount = "hours" + } + return fmt.Sprintf("%d %s", hours, amount) } 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. diff --git a/pkg/util/strings_test.go b/pkg/util/strings_test.go index 0187a37bc63..c6c3d54a2c4 100644 --- a/pkg/util/strings_test.go +++ b/pkg/util/strings_test.go @@ -62,14 +62,14 @@ func TestDateAge(t *testing.T) { assert.Equal(t, "?", GetAgeString(time.Time{})) // base case tests := map[time.Duration]string{ - -1 * time.Hour: "< 1m", // one hour in the future - 0: "< 1m", - 2 * time.Second: "< 1m", - 2 * time.Minute: "2m", - 2 * time.Hour: "2h", - 3 * 24 * time.Hour: "3d", - 67 * 24 * time.Hour: "2M", - 409 * 24 * time.Hour: "1y", + -1 * time.Hour: "< 1 minute", // one hour in the future + 0: "< 1 minute", + 2 * time.Second: "< 1 minute", + 2 * time.Minute: "2 minutes", + 2 * time.Hour: "2 hours", + 3 * 24 * time.Hour: "3 days", + 67 * 24 * time.Hour: "2 months", + 409 * 24 * time.Hour: "1 year", } for elapsed, expected := range tests { assert.Equalf( diff --git a/public/app/features/admin/UserListAdminPage.tsx b/public/app/features/admin/UserListAdminPage.tsx index 209f2b7d533..cd31ed8e581 100644 --- a/public/app/features/admin/UserListAdminPage.tsx +++ b/public/app/features/admin/UserListAdminPage.tsx @@ -1,19 +1,22 @@ import React, { useEffect } from 'react'; import { css, cx } from '@emotion/css'; import { connect, ConnectedProps } from 'react-redux'; -import { Pagination, Tooltip, stylesFactory, LinkButton, Icon } from '@grafana/ui'; -import { AccessControlAction, StoreState, UserDTO } from '../../types'; +import { Pagination, Tooltip, LinkButton, Icon, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; 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 { contextSrv } from 'app/core/core'; 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 = { fetchUsers, changeQuery, changePage, + changeFilter, }; const mapStateToProps = (state: StoreState) => ({ @@ -23,6 +26,8 @@ const mapStateToProps = (state: StoreState) => ({ showPaging: state.userListAdmin.showPaging, totalPages: state.userListAdmin.totalPages, page: state.userListAdmin.page, + filter: state.userListAdmin.filter, + isLoading: state.userListAdmin.isLoading, }); const connector = connect(mapStateToProps, mapDispatchToProps); @@ -31,9 +36,21 @@ interface OwnProps {} type Props = OwnProps & ConnectedProps; -const UserListAdminPageUnConnected: React.FC = (props) => { - const styles = getStyles(); - const { fetchUsers, navModel, query, changeQuery, users, showPaging, totalPages, page, changePage } = props; +const UserListAdminPageUnConnected: React.FC = ({ + fetchUsers, + navModel, + query, + changeQuery, + users, + showPaging, + totalPages, + page, + changePage, + changeFilter, + filter, + isLoading, +}) => { + const styles = useStyles2(getStyles); useEffect(() => { fetchUsers(); @@ -42,45 +59,58 @@ const UserListAdminPageUnConnected: React.FC = (props) => { return ( - <> -
-
- changeQuery(value)} - /> +
+
+ + +
+ {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( + + New user + + )} +
+ {isLoading ? ( + + ) : ( + <> +
+ + + + + + + + + + + + + {users.map(renderUser)} +
LoginEmailNameServer admin + Last active  + + + +
- {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( - - New user - - )} -
-
- - - - - - - - - - - - - {users.map(renderUser)} -
LoginEmailName - Seen  - - - -
-
- {showPaging && } - + {showPaging && } + + )} ); @@ -92,35 +122,44 @@ const renderUser = (user: UserDTO) => { return ( - - + + {`Avatar - + {user.login} - + {user.email} - + {user.name} - {user.lastSeenAtAge && {user.lastSeenAtAge}} {user.isAdmin && ( - + )} + + {user.lastSeenAtAge && ( + + {user.lastSeenAtAge} + + )} + {Array.isArray(user.authLabels) && user.authLabels.length > 0 && ( @@ -133,12 +172,15 @@ const renderUser = (user: UserDTO) => { ); }; -const getStyles = stylesFactory(() => { +const getStyles = (theme: GrafanaTheme2) => { return { table: css` - margin-top: 28px; + margin-top: ${theme.spacing(3)}; + `, + filter: css` + margin-right: ${theme.spacing(1)}; `, }; -}); +}; export default connector(UserListAdminPageUnConnected); diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts index a812192fc5f..71c6a0724ef 100644 --- a/public/app/features/admin/state/actions.ts +++ b/public/app/features/admin/state/actions.ts @@ -19,6 +19,9 @@ import { usersFetched, queryChanged, pageChanged, + filterChanged, + usersFetchBegin, + usersFetchEnd, } from './reducers'; import { debounce } from 'lodash'; import { contextSrv } from 'app/core/core'; @@ -258,10 +261,13 @@ export function clearUserMappingInfo(): ThunkResult { export function fetchUsers(): ThunkResult { return async (dispatch, getState) => { try { - const { perPage, page, query } = getState().userListAdmin; - const result = await getBackendSrv().get(`/api/users/search?perpage=${perPage}&page=${page}&query=${query}`); + const { perPage, page, query, filter } = getState().userListAdmin; + const result = await getBackendSrv().get( + `/api/users/search?perpage=${perPage}&page=${page}&query=${query}&filter=${filter}` + ); dispatch(usersFetched(result)); } catch (error) { + usersFetchEnd(); console.error(error); } }; @@ -271,13 +277,23 @@ const fetchUsersWithDebounce = debounce((dispatch) => dispatch(fetchUsers()), 50 export function changeQuery(query: string): ThunkResult { return async (dispatch) => { + dispatch(usersFetchBegin()); dispatch(queryChanged(query)); fetchUsersWithDebounce(dispatch); }; } +export function changeFilter(filter: string): ThunkResult { + return async (dispatch) => { + dispatch(usersFetchBegin()); + dispatch(filterChanged(filter)); + fetchUsersWithDebounce(dispatch); + }; +} + export function changePage(page: number): ThunkResult { return async (dispatch) => { + dispatch(usersFetchBegin()); dispatch(pageChanged(page)); dispatch(fetchUsers()); }; diff --git a/public/app/features/admin/state/reducers.test.ts b/public/app/features/admin/state/reducers.test.ts index d735ce2fa70..cbd30cbe862 100644 --- a/public/app/features/admin/state/reducers.test.ts +++ b/public/app/features/admin/state/reducers.test.ts @@ -32,6 +32,8 @@ const makeInitialUserListAdminState = (): UserListAdminState => ({ perPage: 50, totalPages: 1, showPaging: false, + filter: 'all', + isLoading: false, }); const getTestUserMapping = (): LdapUser => ({ diff --git a/public/app/features/admin/state/reducers.ts b/public/app/features/admin/state/reducers.ts index 2907b52d708..d619e26f64b 100644 --- a/public/app/features/admin/state/reducers.ts +++ b/public/app/features/admin/state/reducers.ts @@ -128,6 +128,8 @@ const initialUserListAdminState: UserListAdminState = { perPage: 50, totalPages: 1, showPaging: false, + filter: 'all', + isLoading: false, }; interface UsersFetched { @@ -151,8 +153,15 @@ export const userListAdminSlice = createSlice({ totalPages, perPage, showPaging: totalPages > 1, + isLoading: false, }; }, + usersFetchBegin: (state) => { + return { ...state, isLoading: true }; + }, + usersFetchEnd: (state) => { + return { ...state, isLoading: false }; + }, queryChanged: (state, action: PayloadAction) => ({ ...state, query: action.payload, @@ -162,10 +171,21 @@ export const userListAdminSlice = createSlice({ ...state, page: action.payload, }), + filterChanged: (state, action: PayloadAction) => ({ + ...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 default { diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 8d3e29a7dbb..c0a15c40618 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -107,4 +107,6 @@ export interface UserListAdminState { page: number; totalPages: number; showPaging: boolean; + filter: string; + isLoading: boolean; }