Commit be192b81 by Marcus Andersson Committed by GitHub

Chore: migrate admin/users from angular to react + redux (#22759)

* Start adding admin users list page to redux/react.

* removed unused code.

* added pagination.

* changed so we use the new form styles.

* added tooltip.

* using tagbadge for authlabels.

* remove unused code.

* removed old code.

* Fixed the last feedback on PR.
parent f78501f3
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { Pagination } from './Pagination';
# Pagination
<Meta title="MDX|Pagination" component={Pagination} />
<Props of={Pagination}/>
\ No newline at end of file
import React, { useState } from 'react';
import { number } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { Pagination } from './Pagination';
import mdx from './Pagination.mdx';
export const WithPages = () => {
const [page, setPage] = useState(1);
const numberOfPages = number('Number of pages', 5);
return <Pagination numberOfPages={numberOfPages} currentPage={page} onNavigate={setPage} />;
};
export default {
title: 'General/Pagination',
component: WithPages,
decorators: [withCenteredStory],
parameters: {
docs: {
page: mdx,
},
},
};
import React from 'react';
import { css } from 'emotion';
import { stylesFactory } from '../../themes';
import { Button, ButtonVariant } from '../Forms/Button';
interface Props {
currentPage: number;
numberOfPages: number;
onNavigate: (toPage: number) => void;
}
export const Pagination: React.FC<Props> = ({ currentPage, numberOfPages, onNavigate }) => {
const styles = getStyles();
const pages = [...new Array(numberOfPages).keys()];
return (
<div className={styles.container}>
<ol>
{pages.map(pageIndex => {
const page = pageIndex + 1;
const variant: ButtonVariant = page === currentPage ? 'primary' : 'secondary';
return (
<li key={page} className={styles.item}>
<Button size="sm" variant={variant} onClick={() => onNavigate(page)}>
{page}
</Button>
</li>
);
})}
</ol>
</div>
);
};
const getStyles = stylesFactory(() => {
return {
container: css`
float: right;
`,
item: css`
display: inline-block;
padding-left: 10px;
margin-bottom: 5px;
`,
};
});
...@@ -40,6 +40,7 @@ export { TimePicker } from './TimePicker/TimePicker'; ...@@ -40,6 +40,7 @@ export { TimePicker } from './TimePicker/TimePicker';
export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker'; export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker';
export { List } from './List/List'; export { List } from './List/List';
export { TagsInput } from './TagsInput/TagsInput'; export { TagsInput } from './TagsInput/TagsInput';
export { Pagination } from './Pagination/Pagination';
export { ConfirmModal } from './ConfirmModal/ConfirmModal'; export { ConfirmModal } from './ConfirmModal/ConfirmModal';
export { QueryField } from './QueryField/QueryField'; export { QueryField } from './QueryField/QueryField';
......
import { getTagColorsFromName } from '@grafana/ui';
import { getBackendSrv } from '@grafana/runtime';
import { NavModelSrv } from 'app/core/core';
import { Scope } from 'app/types/angular';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
export default class AdminListUsersCtrl {
users: any;
pages: any[] = [];
perPage = 50;
page = 1;
totalPages: number;
showPaging = false;
query: any;
navModel: any;
/** @ngInject */
constructor(private $scope: Scope, navModelSrv: NavModelSrv) {
this.navModel = navModelSrv.getNav('admin', 'global-users', 0);
this.query = '';
this.getUsers();
}
getUsers() {
promiseToDigest(this.$scope)(
getBackendSrv()
.get(`/api/users/search?perpage=${this.perPage}&page=${this.page}&query=${this.query}`)
.then((result: any) => {
this.users = result.users;
this.page = result.page;
this.perPage = result.perPage;
this.totalPages = Math.ceil(result.totalCount / result.perPage);
this.showPaging = this.totalPages > 1;
this.pages = [];
for (let i = 1; i < this.totalPages + 1; i++) {
this.pages.push({ page: i, current: i === this.page });
}
this.addUsersAuthLabels();
})
);
}
navigateToPage(page: any) {
this.page = page.page;
this.getUsers();
}
addUsersAuthLabels() {
for (const user of this.users) {
user.authLabel = getAuthLabel(user);
user.authLabelStyle = getAuthLabelStyle(user.authLabel);
}
}
}
function getAuthLabel(user: any) {
if (user.authLabels && user.authLabels.length) {
return user.authLabels[0];
}
return '';
}
function getAuthLabelStyle(label: string) {
if (label === 'LDAP' || !label) {
return {};
}
const { color, borderColor } = getTagColorsFromName(label);
return {
'background-color': color,
'border-color': borderColor,
};
}
import React, { useEffect } from 'react';
import { css, cx } from 'emotion';
import { hot } from 'react-hot-loader';
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { Pagination, Forms, Tooltip, HorizontalGroup, stylesFactory } from '@grafana/ui';
import { StoreState, UserDTO } from '../../types';
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';
interface OwnProps {}
interface ConnectedProps {
navModel: NavModel;
users: UserDTO[];
query: string;
showPaging: boolean;
totalPages: number;
page: number;
}
interface DispatchProps {
fetchUsers: typeof fetchUsers;
changeQuery: typeof changeQuery;
changePage: typeof changePage;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
const UserListAdminPageUnConnected: React.FC<Props> = props => {
const styles = getStyles();
useEffect(() => {
props.fetchUsers();
}, []);
return (
<Page navModel={props.navModel}>
<Page.Contents>
<>
<div>
<HorizontalGroup justify="space-between">
<Forms.Input
size="md"
type="text"
placeholder="Find user by name/login/email"
tabIndex={1}
autoFocus={true}
value={props.query}
spellCheck={false}
onChange={event => props.changeQuery(event.currentTarget.value)}
prefix={<i className="fa fa-search" />}
/>
<Forms.LinkButton href="admin/users/create" variant="primary">
New user
</Forms.LinkButton>
</HorizontalGroup>
</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>
Seen&nbsp;
<Tooltip placement="top" content="Time since user was seen using Grafana">
<i className="fa fa-question-circle" />
</Tooltip>
</th>
<th></th>
<th style={{ width: '1%' }}></th>
</tr>
</thead>
<tbody>{props.users.map(renderUser)}</tbody>
</table>
</div>
{props.showPaging && (
<Pagination numberOfPages={props.totalPages} currentPage={props.page} onNavigate={props.changePage} />
)}
</>
</Page.Contents>
</Page>
);
};
const renderUser = (user: UserDTO) => {
const editUrl = `admin/users/edit/${user.id}`;
return (
<tr key={user.id}>
<td className="width-4 text-center link-td">
<a href={editUrl}>
<img className="filter-table__avatar" src={user.avatarUrl} />
</a>
</td>
<td className="link-td">
<a href={editUrl}>{user.login}</a>
</td>
<td className="link-td">
<a href={editUrl}>{user.email}</a>
</td>
<td className="link-td">{user.lastSeenAtAge && <a href={editUrl}>{user.lastSeenAtAge}</a>}</td>
<td className="link-td">
{user.isAdmin && (
<a href={editUrl}>
<Tooltip placement="top" content="Grafana Admin">
<i className="fa fa-shield" />
</Tooltip>
</a>
)}
</td>
<td className="text-right">
{Array.isArray(user.authLabels) && user.authLabels.length > 0 && (
<TagBadge label={user.authLabels[0]} removeIcon={false} count={0} />
)}
</td>
<td className="text-right">
{user.isDisabled && <span className="label label-tag label-tag--gray">Disabled</span>}
</td>
</tr>
);
};
const getStyles = stylesFactory(() => {
return {
table: css`
margin-top: 28px;
`,
};
});
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
fetchUsers,
changeQuery,
changePage,
};
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => ({
navModel: getNavModel(state.navIndex, 'global-users'),
users: state.userListAdmin.users,
query: state.userListAdmin.query,
showPaging: state.userListAdmin.showPaging,
totalPages: state.userListAdmin.totalPages,
page: state.userListAdmin.page,
});
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UserListAdminPageUnConnected));
import AdminListUsersCtrl from './AdminListUsersCtrl';
import AdminEditUserCtrl from './AdminEditUserCtrl'; import AdminEditUserCtrl from './AdminEditUserCtrl';
import AdminListOrgsCtrl from './AdminListOrgsCtrl'; import AdminListOrgsCtrl from './AdminListOrgsCtrl';
import AdminEditOrgCtrl from './AdminEditOrgCtrl'; import AdminEditOrgCtrl from './AdminEditOrgCtrl';
...@@ -15,7 +14,6 @@ class AdminHomeCtrl { ...@@ -15,7 +14,6 @@ class AdminHomeCtrl {
} }
} }
coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl);
coreModule.controller('AdminEditUserCtrl', AdminEditUserCtrl); coreModule.controller('AdminEditUserCtrl', AdminEditUserCtrl);
coreModule.controller('AdminListOrgsCtrl', AdminListOrgsCtrl); coreModule.controller('AdminListOrgsCtrl', AdminListOrgsCtrl);
coreModule.controller('AdminEditOrgCtrl', AdminEditOrgCtrl); coreModule.controller('AdminEditOrgCtrl', AdminEditOrgCtrl);
......
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<div class="page-action-bar">
<label class="gf-form gf-form--grow gf-form--has-input-icon">
<input type="text" class="gf-form-input max-width-30" placeholder="Find user by name/login/email" tabindex="1" give-focus="true" ng-model="ctrl.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.getUsers()" />
<i class="gf-form-input-icon fa fa-search"></i>
</label>
<div class="page-action-bar__spacer"></div>
<a class="btn btn-primary" href="admin/users/create">
New user
</a>
</div>
<div class="admin-list-table">
<table class="filter-table form-inline filter-table--hover">
<thead>
<tr>
<th></th>
<th>Login</th>
<th>Email</th>
<th>
Seen
<tip>Time since user was seen using Grafana</tip>
</th>
<th></th>
<th style="width: 1%"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="user in ctrl.users">
<td class="width-4 text-center link-td">
<a href="admin/users/edit/{{user.id}}">
<img class="filter-table__avatar" ng-src="{{user.avatarUrl}}"></img>
</a>
</td>
<td class="link-td">
<a href="admin/users/edit/{{user.id}}">
{{user.login}}
</a>
</td>
<td class="link-td">
<a href="admin/users/edit/{{user.id}}">
{{user.email}}
</a>
</td>
<td class="link-td">
<a href="admin/users/edit/{{user.id}}">
{{user.lastSeenAtAge}}
</a>
</td>
<td class="link-td">
<a href="admin/users/edit/{{user.id}}">
<i class="fa fa-shield" ng-show="user.isAdmin" bs-tooltip="'Grafana Admin'"></i>
</a>
</td>
<td class="text-right">
<span class="label label-tag" ng-style="user.authLabelStyle" ng-if="user.authLabel">
{{user.authLabel}}
</span>
</td>
<td class="text-right">
<span class="label label-tag label-tag--gray" ng-if="user.isDisabled">Disabled</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="admin-list-paging" ng-if="ctrl.showPaging">
<ol>
<li ng-repeat="page in ctrl.pages">
<button class="btn btn-small" ng-class="{'btn-secondary': page.current, 'btn-inverse': !page.current}" ng-click="ctrl.navigateToPage(page)">{{page.page}}</button>
</li>
</ol>
</div>
</div>
<footer />
...@@ -17,7 +17,11 @@ import { ...@@ -17,7 +17,11 @@ import {
clearUserMappingInfoAction, clearUserMappingInfoAction,
clearUserErrorAction, clearUserErrorAction,
ldapFailedAction, ldapFailedAction,
usersFetched,
queryChanged,
pageChanged,
} from './reducers'; } from './reducers';
import { debounce } from 'lodash';
// UserAdminPage // UserAdminPage
...@@ -239,3 +243,33 @@ export function clearUserMappingInfo(): ThunkResult<void> { ...@@ -239,3 +243,33 @@ export function clearUserMappingInfo(): ThunkResult<void> {
dispatch(clearUserMappingInfoAction()); dispatch(clearUserMappingInfoAction());
}; };
} }
// UserListAdminPage
export function fetchUsers(): ThunkResult<void> {
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}`);
dispatch(usersFetched(result));
} catch (error) {
console.error(error);
}
};
}
const fetchUsersWithDebounce = debounce(dispatch => dispatch(fetchUsers()), 500);
export function changeQuery(query: string): ThunkResult<void> {
return async dispatch => {
dispatch(queryChanged(query));
fetchUsersWithDebounce(dispatch);
};
}
export function changePage(page: number): ThunkResult<void> {
return async dispatch => {
dispatch(pageChanged(page));
dispatch(fetchUsers());
};
}
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
UserDTO, UserDTO,
UserOrg, UserOrg,
UserSession, UserSession,
UserListAdminState,
} from 'app/types'; } from 'app/types';
const initialLdapState: LdapState = { const initialLdapState: LdapState = {
...@@ -118,7 +119,56 @@ export const { ...@@ -118,7 +119,56 @@ export const {
export const userAdminReducer = userAdminSlice.reducer; export const userAdminReducer = userAdminSlice.reducer;
// UserListAdminPage
const initialUserListAdminState: UserListAdminState = {
users: [],
query: '',
page: 0,
perPage: 50,
totalPages: 1,
showPaging: false,
};
interface UsersFetched {
users: UserDTO[];
perPage: number;
page: number;
totalCount: number;
}
export const userListAdminSlice = createSlice({
name: 'userListAdmin',
initialState: initialUserListAdminState,
reducers: {
usersFetched: (state, action: PayloadAction<UsersFetched>) => {
const { totalCount, perPage, ...rest } = action.payload;
const totalPages = Math.ceil(totalCount / perPage);
return {
...state,
...rest,
totalPages,
perPage,
showPaging: totalPages > 1,
};
},
queryChanged: (state, action: PayloadAction<string>) => ({
...state,
query: action.payload,
}),
pageChanged: (state, action: PayloadAction<number>) => ({
...state,
page: action.payload,
}),
},
});
export const { usersFetched, queryChanged, pageChanged } = userListAdminSlice.actions;
export const userListAdminReducer = userListAdminSlice.reducer;
export default { export default {
ldap: ldapReducer, ldap: ldapReducer,
userAdmin: userAdminReducer, userAdmin: userAdminReducer,
userListAdmin: userListAdminReducer,
}; };
...@@ -7,12 +7,12 @@ import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportC ...@@ -7,12 +7,12 @@ import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportC
import LdapPage from 'app/features/admin/ldap/LdapPage'; import LdapPage from 'app/features/admin/ldap/LdapPage';
import UserAdminPage from 'app/features/admin/UserAdminPage'; import UserAdminPage from 'app/features/admin/UserAdminPage';
import SignupPage from 'app/features/profile/SignupPage'; import SignupPage from 'app/features/profile/SignupPage';
import { LoginPage } from 'app/core/components/Login/LoginPage';
import config from 'app/core/config'; import config from 'app/core/config';
import { ILocationProvider, route } from 'angular'; import { ILocationProvider, route } from 'angular';
// Types // Types
import { DashboardRouteInfo } from 'app/types'; import { DashboardRouteInfo } from 'app/types';
import { LoginPage } from 'app/core/components/Login/LoginPage';
import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamicImport'; import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamicImport';
/** @ngInject */ /** @ngInject */
...@@ -304,9 +304,11 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati ...@@ -304,9 +304,11 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
}, },
}) })
.when('/admin/users', { .when('/admin/users', {
templateUrl: 'public/app/features/admin/partials/users.html', template: '<react-container />',
controller: 'AdminListUsersCtrl', resolve: {
controllerAs: 'ctrl', component: () =>
SafeDynamicImport(import(/* webpackChunkName: "UserListAdminPage" */ 'app/features/admin/UserListAdminPage')),
},
}) })
.when('/admin/users/create', { .when('/admin/users/create', {
template: '<react-container />', template: '<react-container />',
......
...@@ -9,7 +9,7 @@ import { FolderState } from './folders'; ...@@ -9,7 +9,7 @@ import { FolderState } from './folders';
import { DashboardState } from './dashboard'; import { DashboardState } from './dashboard';
import { DataSourceSettingsState, DataSourcesState } from './datasources'; import { DataSourceSettingsState, DataSourcesState } from './datasources';
import { ExploreState } from './explore'; import { ExploreState } from './explore';
import { UserAdminState, UsersState, UserState } from './user'; import { UserAdminState, UserListAdminState, UsersState, UserState } from './user';
import { OrganizationState } from './organization'; import { OrganizationState } from './organization';
import { AppNotificationsState } from './appNotifications'; import { AppNotificationsState } from './appNotifications';
import { PluginsState } from './plugins'; import { PluginsState } from './plugins';
...@@ -42,6 +42,7 @@ export interface StoreState { ...@@ -42,6 +42,7 @@ export interface StoreState {
ldap: LdapState; ldap: LdapState;
apiKeys: ApiKeysState; apiKeys: ApiKeysState;
userAdmin: UserAdminState; userAdmin: UserAdminState;
userListAdmin: UserListAdminState;
templating: TemplatingState; templating: TemplatingState;
} }
......
...@@ -29,12 +29,14 @@ export interface UserDTO { ...@@ -29,12 +29,14 @@ export interface UserDTO {
name: string; name: string;
isGrafanaAdmin: boolean; isGrafanaAdmin: boolean;
isDisabled: boolean; isDisabled: boolean;
isAdmin?: boolean;
isExternal?: boolean; isExternal?: boolean;
updatedAt?: string; updatedAt?: string;
authLabels?: string[]; authLabels?: string[];
theme?: string; theme?: string;
avatarUrl?: string; avatarUrl?: string;
orgId?: number; orgId?: number;
lastSeenAtAge?: string;
} }
export interface Invitee { export interface Invitee {
...@@ -101,3 +103,12 @@ export interface UserAdminError { ...@@ -101,3 +103,12 @@ export interface UserAdminError {
title: string; title: string;
body: string; body: string;
} }
export interface UserListAdminState {
users: UserDTO[];
query: string;
perPage: number;
page: number;
totalPages: number;
showPaging: boolean;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment