mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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.
This commit is contained in:
parent
f78501f3b5
commit
be192b8191
@ -0,0 +1,8 @@
|
||||
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
# Pagination
|
||||
|
||||
<Meta title="MDX|Pagination" component={Pagination} />
|
||||
|
||||
<Props of={Pagination}/>
|
@ -0,0 +1,22 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
47
packages/grafana-ui/src/components/Pagination/Pagination.tsx
Normal file
47
packages/grafana-ui/src/components/Pagination/Pagination.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
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';
|
||||
export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker';
|
||||
export { List } from './List/List';
|
||||
export { TagsInput } from './TagsInput/TagsInput';
|
||||
export { Pagination } from './Pagination/Pagination';
|
||||
|
||||
export { ConfirmModal } from './ConfirmModal/ConfirmModal';
|
||||
export { QueryField } from './QueryField/QueryField';
|
||||
|
@ -1,75 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
152
public/app/features/admin/UserListAdminPage.tsx
Normal file
152
public/app/features/admin/UserListAdminPage.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
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
|
||||
<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));
|
@ -1,4 +1,3 @@
|
||||
import AdminListUsersCtrl from './AdminListUsersCtrl';
|
||||
import AdminEditUserCtrl from './AdminEditUserCtrl';
|
||||
import AdminListOrgsCtrl from './AdminListOrgsCtrl';
|
||||
import AdminEditOrgCtrl from './AdminEditOrgCtrl';
|
||||
@ -15,7 +14,6 @@ class AdminHomeCtrl {
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl);
|
||||
coreModule.controller('AdminEditUserCtrl', AdminEditUserCtrl);
|
||||
coreModule.controller('AdminListOrgsCtrl', AdminListOrgsCtrl);
|
||||
coreModule.controller('AdminEditOrgCtrl', AdminEditOrgCtrl);
|
||||
|
@ -1,80 +0,0 @@
|
||||
<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 {
|
||||
clearUserMappingInfoAction,
|
||||
clearUserErrorAction,
|
||||
ldapFailedAction,
|
||||
usersFetched,
|
||||
queryChanged,
|
||||
pageChanged,
|
||||
} from './reducers';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
// UserAdminPage
|
||||
|
||||
@ -239,3 +243,33 @@ export function clearUserMappingInfo(): ThunkResult<void> {
|
||||
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 {
|
||||
UserDTO,
|
||||
UserOrg,
|
||||
UserSession,
|
||||
UserListAdminState,
|
||||
} from 'app/types';
|
||||
|
||||
const initialLdapState: LdapState = {
|
||||
@ -118,7 +119,56 @@ export const {
|
||||
|
||||
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 {
|
||||
ldap: ldapReducer,
|
||||
userAdmin: userAdminReducer,
|
||||
userListAdmin: userListAdminReducer,
|
||||
};
|
||||
|
@ -7,12 +7,12 @@ import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportC
|
||||
import LdapPage from 'app/features/admin/ldap/LdapPage';
|
||||
import UserAdminPage from 'app/features/admin/UserAdminPage';
|
||||
import SignupPage from 'app/features/profile/SignupPage';
|
||||
import { LoginPage } from 'app/core/components/Login/LoginPage';
|
||||
|
||||
import config from 'app/core/config';
|
||||
import { ILocationProvider, route } from 'angular';
|
||||
// Types
|
||||
import { DashboardRouteInfo } from 'app/types';
|
||||
import { LoginPage } from 'app/core/components/Login/LoginPage';
|
||||
import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamicImport';
|
||||
|
||||
/** @ngInject */
|
||||
@ -304,9 +304,11 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
|
||||
},
|
||||
})
|
||||
.when('/admin/users', {
|
||||
templateUrl: 'public/app/features/admin/partials/users.html',
|
||||
controller: 'AdminListUsersCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () =>
|
||||
SafeDynamicImport(import(/* webpackChunkName: "UserListAdminPage" */ 'app/features/admin/UserListAdminPage')),
|
||||
},
|
||||
})
|
||||
.when('/admin/users/create', {
|
||||
template: '<react-container />',
|
||||
|
@ -9,7 +9,7 @@ import { FolderState } from './folders';
|
||||
import { DashboardState } from './dashboard';
|
||||
import { DataSourceSettingsState, DataSourcesState } from './datasources';
|
||||
import { ExploreState } from './explore';
|
||||
import { UserAdminState, UsersState, UserState } from './user';
|
||||
import { UserAdminState, UserListAdminState, UsersState, UserState } from './user';
|
||||
import { OrganizationState } from './organization';
|
||||
import { AppNotificationsState } from './appNotifications';
|
||||
import { PluginsState } from './plugins';
|
||||
@ -42,6 +42,7 @@ export interface StoreState {
|
||||
ldap: LdapState;
|
||||
apiKeys: ApiKeysState;
|
||||
userAdmin: UserAdminState;
|
||||
userListAdmin: UserListAdminState;
|
||||
templating: TemplatingState;
|
||||
}
|
||||
|
||||
|
@ -29,12 +29,14 @@ export interface UserDTO {
|
||||
name: string;
|
||||
isGrafanaAdmin: boolean;
|
||||
isDisabled: boolean;
|
||||
isAdmin?: boolean;
|
||||
isExternal?: boolean;
|
||||
updatedAt?: string;
|
||||
authLabels?: string[];
|
||||
theme?: string;
|
||||
avatarUrl?: string;
|
||||
orgId?: number;
|
||||
lastSeenAtAge?: string;
|
||||
}
|
||||
|
||||
export interface Invitee {
|
||||
@ -101,3 +103,12 @@ export interface UserAdminError {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface UserListAdminState {
|
||||
users: UserDTO[];
|
||||
query: string;
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
showPaging: boolean;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user