mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge pull request #13488 from grafana/org-users-to-react
Org users to react
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import OrgActionBar, { Props } from './OrgActionBar';
|
||||||
|
|
||||||
|
const setup = (propOverrides?: object) => {
|
||||||
|
const props: Props = {
|
||||||
|
searchQuery: '',
|
||||||
|
setSearchQuery: jest.fn(),
|
||||||
|
linkButton: { href: 'some/url', title: 'test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(props, propOverrides);
|
||||||
|
|
||||||
|
return shallow(<OrgActionBar {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Render', () => {
|
||||||
|
it('should render component', () => {
|
||||||
|
const wrapper = setup();
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
38
public/app/core/components/OrgActionBar/OrgActionBar.tsx
Normal file
38
public/app/core/components/OrgActionBar/OrgActionBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import LayoutSelector, { LayoutMode } from '../LayoutSelector/LayoutSelector';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
searchQuery: string;
|
||||||
|
layoutMode?: LayoutMode;
|
||||||
|
setLayoutMode?: (mode: LayoutMode) => {};
|
||||||
|
setSearchQuery: (value: string) => {};
|
||||||
|
linkButton: { href: string; title: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class OrgActionBar extends PureComponent<Props> {
|
||||||
|
render() {
|
||||||
|
const { searchQuery, layoutMode, setLayoutMode, linkButton, setSearchQuery } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-action-bar">
|
||||||
|
<div className="gf-form gf-form--grow">
|
||||||
|
<label className="gf-form--has-input-icon">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="gf-form-input width-20"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={event => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Filter by name or type"
|
||||||
|
/>
|
||||||
|
<i className="gf-form-input-icon fa fa-search" />
|
||||||
|
</label>
|
||||||
|
<LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
|
||||||
|
</div>
|
||||||
|
<div className="page-action-bar__spacer" />
|
||||||
|
<a className="btn btn-success" href={linkButton.href} target="_blank">
|
||||||
|
{linkButton.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -22,7 +22,6 @@ exports[`Render should render component 1`] = `
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<LayoutSelector
|
<LayoutSelector
|
||||||
mode="grid"
|
|
||||||
onLayoutModeChanged={[Function]}
|
onLayoutModeChanged={[Function]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,10 +30,10 @@ exports[`Render should render component 1`] = `
|
|||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
|
href="some/url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
Find more plugins on Grafana.com
|
test
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { shallow } from 'enzyme';
|
|
||||||
import { DataSourcesActionBar, Props } from './DataSourcesActionBar';
|
|
||||||
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
|
|
||||||
|
|
||||||
const setup = (propOverrides?: object) => {
|
|
||||||
const props: Props = {
|
|
||||||
layoutMode: LayoutModes.Grid,
|
|
||||||
searchQuery: '',
|
|
||||||
setDataSourcesLayoutMode: jest.fn(),
|
|
||||||
setDataSourcesSearchQuery: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return shallow(<DataSourcesActionBar {...props} />);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Render', () => {
|
|
||||||
it('should render component', () => {
|
|
||||||
const wrapper = setup();
|
|
||||||
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,62 +0,0 @@
|
|||||||
import React, { PureComponent } from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
|
||||||
import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
|
|
||||||
import { getDataSourcesLayoutMode, getDataSourcesSearchQuery } from './state/selectors';
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
searchQuery: string;
|
|
||||||
layoutMode: LayoutMode;
|
|
||||||
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
|
|
||||||
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DataSourcesActionBar extends PureComponent<Props> {
|
|
||||||
onSearchQueryChange = event => {
|
|
||||||
this.props.setDataSourcesSearchQuery(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { searchQuery, layoutMode, setDataSourcesLayoutMode } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-action-bar">
|
|
||||||
<div className="gf-form gf-form--grow">
|
|
||||||
<label className="gf-form--has-input-icon">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="gf-form-input width-20"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={this.onSearchQueryChange}
|
|
||||||
placeholder="Filter by name or type"
|
|
||||||
/>
|
|
||||||
<i className="gf-form-input-icon fa fa-search" />
|
|
||||||
</label>
|
|
||||||
<LayoutSelector
|
|
||||||
mode={layoutMode}
|
|
||||||
onLayoutModeChanged={(mode: LayoutMode) => setDataSourcesLayoutMode(mode)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="page-action-bar__spacer" />
|
|
||||||
<a className="page-header__cta btn btn-success" href="datasources/new">
|
|
||||||
<i className="fa fa-plus" />
|
|
||||||
Add data source
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStateToProps(state) {
|
|
||||||
return {
|
|
||||||
searchQuery: getDataSourcesSearchQuery(state.dataSources),
|
|
||||||
layoutMode: getDataSourcesLayoutMode(state.dataSources),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
setDataSourcesLayoutMode,
|
|
||||||
setDataSourcesSearchQuery,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(DataSourcesActionBar);
|
|
@@ -12,6 +12,9 @@ const setup = (propOverrides?: object) => {
|
|||||||
loadDataSources: jest.fn(),
|
loadDataSources: jest.fn(),
|
||||||
navModel: {} as NavModel,
|
navModel: {} as NavModel,
|
||||||
dataSourcesCount: 0,
|
dataSourcesCount: 0,
|
||||||
|
searchQuery: '',
|
||||||
|
setDataSourcesSearchQuery: jest.fn(),
|
||||||
|
setDataSourcesLayoutMode: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(props, propOverrides);
|
Object.assign(props, propOverrides);
|
||||||
|
@@ -2,21 +2,29 @@ import React, { PureComponent } from 'react';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { hot } from 'react-hot-loader';
|
import { hot } from 'react-hot-loader';
|
||||||
import PageHeader from '../../core/components/PageHeader/PageHeader';
|
import PageHeader from '../../core/components/PageHeader/PageHeader';
|
||||||
import DataSourcesActionBar from './DataSourcesActionBar';
|
import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
|
||||||
|
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
|
||||||
import DataSourcesList from './DataSourcesList';
|
import DataSourcesList from './DataSourcesList';
|
||||||
import { loadDataSources } from './state/actions';
|
|
||||||
import { getDataSources, getDataSourcesCount, getDataSourcesLayoutMode } from './state/selectors';
|
|
||||||
import { getNavModel } from '../../core/selectors/navModel';
|
|
||||||
import { DataSource, NavModel } from 'app/types';
|
import { DataSource, NavModel } from 'app/types';
|
||||||
import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||||
import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
|
import { loadDataSources, setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
|
||||||
|
import { getNavModel } from '../../core/selectors/navModel';
|
||||||
|
import {
|
||||||
|
getDataSources,
|
||||||
|
getDataSourcesCount,
|
||||||
|
getDataSourcesLayoutMode,
|
||||||
|
getDataSourcesSearchQuery,
|
||||||
|
} from './state/selectors';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
navModel: NavModel;
|
navModel: NavModel;
|
||||||
dataSources: DataSource[];
|
dataSources: DataSource[];
|
||||||
dataSourcesCount: number;
|
dataSourcesCount: number;
|
||||||
layoutMode: LayoutMode;
|
layoutMode: LayoutMode;
|
||||||
|
searchQuery: string;
|
||||||
loadDataSources: typeof loadDataSources;
|
loadDataSources: typeof loadDataSources;
|
||||||
|
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
|
||||||
|
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyListModel = {
|
const emptyListModel = {
|
||||||
@@ -40,7 +48,20 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { dataSources, dataSourcesCount, navModel, layoutMode } = this.props;
|
const {
|
||||||
|
dataSources,
|
||||||
|
dataSourcesCount,
|
||||||
|
navModel,
|
||||||
|
layoutMode,
|
||||||
|
searchQuery,
|
||||||
|
setDataSourcesSearchQuery,
|
||||||
|
setDataSourcesLayoutMode,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const linkButton = {
|
||||||
|
href: 'datasources/new',
|
||||||
|
title: 'Add data source',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -50,7 +71,14 @@ export class DataSourcesListPage extends PureComponent<Props> {
|
|||||||
<EmptyListCTA model={emptyListModel} />
|
<EmptyListCTA model={emptyListModel} />
|
||||||
) : (
|
) : (
|
||||||
[
|
[
|
||||||
<DataSourcesActionBar key="action-bar" />,
|
<OrgActionBar
|
||||||
|
layoutMode={layoutMode}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setLayoutMode={mode => setDataSourcesLayoutMode(mode)}
|
||||||
|
setSearchQuery={query => setDataSourcesSearchQuery(query)}
|
||||||
|
linkButton={linkButton}
|
||||||
|
key="action-bar"
|
||||||
|
/>,
|
||||||
<DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
|
<DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
|
||||||
]
|
]
|
||||||
)}
|
)}
|
||||||
@@ -66,11 +94,14 @@ function mapStateToProps(state) {
|
|||||||
dataSources: getDataSources(state.dataSources),
|
dataSources: getDataSources(state.dataSources),
|
||||||
layoutMode: getDataSourcesLayoutMode(state.dataSources),
|
layoutMode: getDataSourcesLayoutMode(state.dataSources),
|
||||||
dataSourcesCount: getDataSourcesCount(state.dataSources),
|
dataSourcesCount: getDataSourcesCount(state.dataSources),
|
||||||
|
searchQuery: getDataSourcesSearchQuery(state.dataSources),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
loadDataSources,
|
loadDataSources,
|
||||||
|
setDataSourcesSearchQuery,
|
||||||
|
setDataSourcesLayoutMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage));
|
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage));
|
||||||
|
@@ -1,42 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`Render should render component 1`] = `
|
|
||||||
<div
|
|
||||||
className="page-action-bar"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="gf-form gf-form--grow"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="gf-form--has-input-icon"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="gf-form-input width-20"
|
|
||||||
onChange={[Function]}
|
|
||||||
placeholder="Filter by name or type"
|
|
||||||
type="text"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
className="gf-form-input-icon fa fa-search"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<LayoutSelector
|
|
||||||
mode="grid"
|
|
||||||
onLayoutModeChanged={[Function]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="page-action-bar__spacer"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
className="page-header__cta btn btn-success"
|
|
||||||
href="datasources/new"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="fa fa-plus"
|
|
||||||
/>
|
|
||||||
Add data source
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
@@ -8,8 +8,18 @@ exports[`Render should render action bar and datasources 1`] = `
|
|||||||
<div
|
<div
|
||||||
className="page-container page-body"
|
className="page-container page-body"
|
||||||
>
|
>
|
||||||
<Connect(DataSourcesActionBar)
|
<OrgActionBar
|
||||||
key="action-bar"
|
key="action-bar"
|
||||||
|
layoutMode="grid"
|
||||||
|
linkButton={
|
||||||
|
Object {
|
||||||
|
"href": "datasources/new",
|
||||||
|
"title": "Add data source",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchQuery=""
|
||||||
|
setLayoutMode={[Function]}
|
||||||
|
setSearchQuery={[Function]}
|
||||||
/>
|
/>
|
||||||
<DataSourcesList
|
<DataSourcesList
|
||||||
dataSources={
|
dataSources={
|
||||||
|
@@ -1,6 +1,4 @@
|
|||||||
import './org_users_ctrl';
|
|
||||||
import './profile_ctrl';
|
import './profile_ctrl';
|
||||||
import './org_users_ctrl';
|
|
||||||
import './select_org_ctrl';
|
import './select_org_ctrl';
|
||||||
import './change_password_ctrl';
|
import './change_password_ctrl';
|
||||||
import './new_org_ctrl';
|
import './new_org_ctrl';
|
||||||
|
@@ -1,87 +0,0 @@
|
|||||||
import config from 'app/core/config';
|
|
||||||
import coreModule from 'app/core/core_module';
|
|
||||||
import Remarkable from 'remarkable';
|
|
||||||
import _ from 'lodash';
|
|
||||||
|
|
||||||
export class OrgUsersCtrl {
|
|
||||||
unfiltered: any;
|
|
||||||
users: any;
|
|
||||||
pendingInvites: any;
|
|
||||||
editor: any;
|
|
||||||
navModel: any;
|
|
||||||
externalUserMngLinkUrl: string;
|
|
||||||
externalUserMngLinkName: string;
|
|
||||||
externalUserMngInfo: string;
|
|
||||||
canInvite: boolean;
|
|
||||||
searchQuery: string;
|
|
||||||
showInvites: boolean;
|
|
||||||
|
|
||||||
/** @ngInject */
|
|
||||||
constructor(private $scope, private backendSrv, navModelSrv, $sce) {
|
|
||||||
this.navModel = navModelSrv.getNav('cfg', 'users', 0);
|
|
||||||
|
|
||||||
this.get();
|
|
||||||
this.externalUserMngLinkUrl = config.externalUserMngLinkUrl;
|
|
||||||
this.externalUserMngLinkName = config.externalUserMngLinkName;
|
|
||||||
this.canInvite = !config.disableLoginForm && !config.externalUserMngLinkName;
|
|
||||||
|
|
||||||
// render external user management info markdown
|
|
||||||
if (config.externalUserMngInfo) {
|
|
||||||
this.externalUserMngInfo = new Remarkable({
|
|
||||||
linkTarget: '__blank',
|
|
||||||
}).render(config.externalUserMngInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get() {
|
|
||||||
this.backendSrv.get('/api/org/users').then(users => {
|
|
||||||
this.users = users;
|
|
||||||
this.unfiltered = users;
|
|
||||||
});
|
|
||||||
this.backendSrv.get('/api/org/invites').then(pendingInvites => {
|
|
||||||
this.pendingInvites = pendingInvites;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onQueryUpdated() {
|
|
||||||
const regex = new RegExp(this.searchQuery, 'ig');
|
|
||||||
this.users = _.filter(this.unfiltered, item => {
|
|
||||||
return regex.test(item.email) || regex.test(item.login);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateOrgUser(user) {
|
|
||||||
this.backendSrv.patch('/api/org/users/' + user.userId, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeUser(user) {
|
|
||||||
this.$scope.appEvent('confirm-modal', {
|
|
||||||
title: 'Delete',
|
|
||||||
text: 'Are you sure you want to delete user ' + user.login + '?',
|
|
||||||
yesText: 'Delete',
|
|
||||||
icon: 'fa-warning',
|
|
||||||
onConfirm: () => {
|
|
||||||
this.removeUserConfirmed(user);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
removeUserConfirmed(user) {
|
|
||||||
this.backendSrv.delete('/api/org/users/' + user.userId).then(this.get.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
revokeInvite(invite, evt) {
|
|
||||||
evt.stopPropagation();
|
|
||||||
this.backendSrv.patch('/api/org/invites/' + invite.code + '/revoke').then(this.get.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
copyInviteToClipboard(evt) {
|
|
||||||
evt.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
getInviteUrl(invite) {
|
|
||||||
return invite.url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
coreModule.controller('OrgUsersCtrl', OrgUsersCtrl);
|
|
@@ -1,105 +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--has-input-icon">
|
|
||||||
<input type="text" class="gf-form-input width-20" ng-model="ctrl.searchQuery" ng-change="ctrl.onQueryUpdated()" placeholder="Filter by username or email" />
|
|
||||||
<i class="gf-form-input-icon fa fa-search"></i>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div ng-if="ctrl.pendingInvites.length" style="margin-left: 1rem">
|
|
||||||
<button class="btn toggle-btn active" ng-if="!ctrl.showInvites">
|
|
||||||
Users
|
|
||||||
</button><button class="btn toggle-btn" ng-if="!ctrl.showInvites" ng-click="ctrl.showInvites = true">
|
|
||||||
Pending Invites ({{ctrl.pendingInvites.length}})
|
|
||||||
</button>
|
|
||||||
<button class="btn toggle-btn" ng-if="ctrl.showInvites" ng-click="ctrl.showInvites = false">
|
|
||||||
Users
|
|
||||||
</button><button class="btn toggle-btn active" ng-if="ctrl.showInvites">
|
|
||||||
Pending Invites ({{ctrl.pendingInvites.length}})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-action-bar__spacer"></div>
|
|
||||||
|
|
||||||
<a class="btn btn-success" href="org/users/invite" ng-show="ctrl.canInvite">
|
|
||||||
<i class="fa fa-plus"></i>
|
|
||||||
<span>Invite</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="btn btn-success" ng-href="{{ctrl.externalUserMngLinkUrl}}" target="_blank" ng-if="ctrl.externalUserMngLinkUrl">
|
|
||||||
<i class="fa fa-external-link-square"></i>
|
|
||||||
{{ctrl.externalUserMngLinkName}}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grafana-info-box" ng-if="ctrl.externalUserMngInfo">
|
|
||||||
<span ng-bind-html="ctrl.externalUserMngInfo"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ng-hide="ctrl.showInvites">
|
|
||||||
<table class="filter-table form-inline">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th>Login</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>
|
|
||||||
Seen
|
|
||||||
<tip>Time since user was seen using Grafana</tip>
|
|
||||||
</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th style="width: 34px;"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tr ng-repeat="user in ctrl.users">
|
|
||||||
<td class="width-4 text-center">
|
|
||||||
<img class="filter-table__avatar" ng-src="{{user.avatarUrl}}"></img>
|
|
||||||
</td>
|
|
||||||
<td>{{user.login}}</td>
|
|
||||||
<td><span class="ellipsis">{{user.email}}</span></td>
|
|
||||||
<td>{{user.lastSeenAtAge}}</td>
|
|
||||||
<td>
|
|
||||||
<div class="gf-form-select-wrapper width-12">
|
|
||||||
<select type="text" ng-model="user.role" class="gf-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Admin']" ng-change="ctrl.updateOrgUser(user)">
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a ng-click="ctrl.removeUser(user)" class="btn btn-danger btn-mini">
|
|
||||||
<i class="fa fa-remove"></i>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ng-if="ctrl.showInvites">
|
|
||||||
<table class="filter-table form-inline">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th></th>
|
|
||||||
<th style="width: 34px;"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tr ng-repeat="invite in ctrl.pendingInvites">
|
|
||||||
<td>{{invite.email}}</td>
|
|
||||||
<td>{{invite.name}}</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<button class="btn btn-inverse btn-mini" clipboard-button="ctrl.getInviteUrl(invite)" ng-click="ctrl.copyInviteToClipboard($event)">
|
|
||||||
<i class="fa fa-clipboard"></i> Copy Invite
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button class="btn btn-danger btn-mini" ng-click="ctrl.revokeInvite(invite, $event)">
|
|
||||||
<i class="fa fa-remove"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@@ -1,31 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { shallow } from 'enzyme';
|
|
||||||
import { PluginActionBar, Props } from './PluginActionBar';
|
|
||||||
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
|
|
||||||
|
|
||||||
const setup = (propOverrides?: object) => {
|
|
||||||
const props: Props = {
|
|
||||||
searchQuery: '',
|
|
||||||
layoutMode: LayoutModes.Grid,
|
|
||||||
setLayoutMode: jest.fn(),
|
|
||||||
setPluginsSearchQuery: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.assign(props, propOverrides);
|
|
||||||
|
|
||||||
const wrapper = shallow(<PluginActionBar {...props} />);
|
|
||||||
const instance = wrapper.instance() as PluginActionBar;
|
|
||||||
|
|
||||||
return {
|
|
||||||
wrapper,
|
|
||||||
instance,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Render', () => {
|
|
||||||
it('should render component', () => {
|
|
||||||
const { wrapper } = setup();
|
|
||||||
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,62 +0,0 @@
|
|||||||
import React, { PureComponent } from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
|
||||||
import { setLayoutMode, setPluginsSearchQuery } from './state/actions';
|
|
||||||
import { getPluginsSearchQuery, getLayoutMode } from './state/selectors';
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
searchQuery: string;
|
|
||||||
layoutMode: LayoutMode;
|
|
||||||
setLayoutMode: typeof setLayoutMode;
|
|
||||||
setPluginsSearchQuery: typeof setPluginsSearchQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PluginActionBar extends PureComponent<Props> {
|
|
||||||
onSearchQueryChange = event => {
|
|
||||||
this.props.setPluginsSearchQuery(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { searchQuery, layoutMode, setLayoutMode } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-action-bar">
|
|
||||||
<div className="gf-form gf-form--grow">
|
|
||||||
<label className="gf-form--has-input-icon">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="gf-form-input width-20"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={this.onSearchQueryChange}
|
|
||||||
placeholder="Filter by name or type"
|
|
||||||
/>
|
|
||||||
<i className="gf-form-input-icon fa fa-search" />
|
|
||||||
</label>
|
|
||||||
<LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
|
|
||||||
</div>
|
|
||||||
<div className="page-action-bar__spacer" />
|
|
||||||
<a
|
|
||||||
className="btn btn-success"
|
|
||||||
href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Find more plugins on Grafana.com
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStateToProps(state) {
|
|
||||||
return {
|
|
||||||
searchQuery: getPluginsSearchQuery(state.plugins),
|
|
||||||
layoutMode: getLayoutMode(state.plugins),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
setPluginsSearchQuery,
|
|
||||||
setLayoutMode,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(PluginActionBar);
|
|
@@ -8,6 +8,9 @@ const setup = (propOverrides?: object) => {
|
|||||||
const props: Props = {
|
const props: Props = {
|
||||||
navModel: {} as NavModel,
|
navModel: {} as NavModel,
|
||||||
plugins: [] as Plugin[],
|
plugins: [] as Plugin[],
|
||||||
|
searchQuery: '',
|
||||||
|
setPluginsSearchQuery: jest.fn(),
|
||||||
|
setPluginsLayoutMode: jest.fn(),
|
||||||
layoutMode: LayoutModes.Grid,
|
layoutMode: LayoutModes.Grid,
|
||||||
loadPlugins: jest.fn(),
|
loadPlugins: jest.fn(),
|
||||||
};
|
};
|
||||||
|
@@ -1,20 +1,23 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { hot } from 'react-hot-loader';
|
import { hot } from 'react-hot-loader';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import PageHeader from '../../core/components/PageHeader/PageHeader';
|
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||||
import PluginActionBar from './PluginActionBar';
|
import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
|
||||||
import PluginList from './PluginList';
|
import PluginList from './PluginList';
|
||||||
import { NavModel, Plugin } from '../../types';
|
import { NavModel, Plugin } from 'app/types';
|
||||||
import { loadPlugins } from './state/actions';
|
import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
|
||||||
import { getNavModel } from '../../core/selectors/navModel';
|
import { getNavModel } from '../../core/selectors/navModel';
|
||||||
import { getLayoutMode, getPlugins } from './state/selectors';
|
import { getLayoutMode, getPlugins, getPluginsSearchQuery } from './state/selectors';
|
||||||
import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
navModel: NavModel;
|
navModel: NavModel;
|
||||||
plugins: Plugin[];
|
plugins: Plugin[];
|
||||||
layoutMode: LayoutMode;
|
layoutMode: LayoutMode;
|
||||||
|
searchQuery: string;
|
||||||
loadPlugins: typeof loadPlugins;
|
loadPlugins: typeof loadPlugins;
|
||||||
|
setPluginsLayoutMode: typeof setPluginsLayoutMode;
|
||||||
|
setPluginsSearchQuery: typeof setPluginsSearchQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginListPage extends PureComponent<Props> {
|
export class PluginListPage extends PureComponent<Props> {
|
||||||
@@ -27,13 +30,23 @@ export class PluginListPage extends PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { navModel, plugins, layoutMode } = this.props;
|
const { navModel, plugins, layoutMode, setPluginsLayoutMode, setPluginsSearchQuery, searchQuery } = this.props;
|
||||||
|
|
||||||
|
const linkButton = {
|
||||||
|
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
|
||||||
|
title: 'Find more plugins on Grafana.com',
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader model={navModel} />
|
<PageHeader model={navModel} />
|
||||||
<div className="page-container page-body">
|
<div className="page-container page-body">
|
||||||
<PluginActionBar />
|
<OrgActionBar
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
layoutMode={layoutMode}
|
||||||
|
setLayoutMode={mode => setPluginsLayoutMode(mode)}
|
||||||
|
setSearchQuery={query => setPluginsSearchQuery(query)}
|
||||||
|
linkButton={linkButton}
|
||||||
|
/>
|
||||||
{plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
|
{plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,11 +59,14 @@ function mapStateToProps(state) {
|
|||||||
navModel: getNavModel(state.navIndex, 'plugins'),
|
navModel: getNavModel(state.navIndex, 'plugins'),
|
||||||
plugins: getPlugins(state.plugins),
|
plugins: getPlugins(state.plugins),
|
||||||
layoutMode: getLayoutMode(state.plugins),
|
layoutMode: getLayoutMode(state.plugins),
|
||||||
|
searchQuery: getPluginsSearchQuery(state.plugins),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
loadPlugins,
|
loadPlugins,
|
||||||
|
setPluginsLayoutMode,
|
||||||
|
setPluginsSearchQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));
|
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));
|
||||||
|
@@ -8,7 +8,18 @@ exports[`Render should render component 1`] = `
|
|||||||
<div
|
<div
|
||||||
className="page-container page-body"
|
className="page-container page-body"
|
||||||
>
|
>
|
||||||
<Connect(PluginActionBar) />
|
<OrgActionBar
|
||||||
|
layoutMode="grid"
|
||||||
|
linkButton={
|
||||||
|
Object {
|
||||||
|
"href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
|
||||||
|
"title": "Find more plugins on Grafana.com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchQuery=""
|
||||||
|
setLayoutMode={[Function]}
|
||||||
|
setSearchQuery={[Function]}
|
||||||
|
/>
|
||||||
<PluginList
|
<PluginList
|
||||||
layoutMode="grid"
|
layoutMode="grid"
|
||||||
plugins={Array []}
|
plugins={Array []}
|
||||||
|
@@ -24,7 +24,7 @@ export interface SetLayoutModeAction {
|
|||||||
payload: LayoutMode;
|
payload: LayoutMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
|
export const setPluginsLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
|
||||||
type: ActionTypes.SetLayoutMode,
|
type: ActionTypes.SetLayoutMode,
|
||||||
payload: mode,
|
payload: mode,
|
||||||
});
|
});
|
||||||
|
32
public/app/features/users/InviteesTable.test.tsx
Normal file
32
public/app/features/users/InviteesTable.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import InviteesTable, { Props } from './InviteesTable';
|
||||||
|
import { Invitee } from 'app/types';
|
||||||
|
import { getMockInvitees } from './__mocks__/userMocks';
|
||||||
|
|
||||||
|
const setup = (propOverrides?: object) => {
|
||||||
|
const props: Props = {
|
||||||
|
invitees: [] as Invitee[],
|
||||||
|
revokeInvite: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(props, propOverrides);
|
||||||
|
|
||||||
|
return shallow(<InviteesTable {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Render', () => {
|
||||||
|
it('should render component', () => {
|
||||||
|
const wrapper = setup();
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render invitees', () => {
|
||||||
|
const wrapper = setup({
|
||||||
|
invitees: getMockInvitees(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
64
public/app/features/users/InviteesTable.tsx
Normal file
64
public/app/features/users/InviteesTable.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { createRef, PureComponent } from 'react';
|
||||||
|
import { Invitee } from 'app/types';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
invitees: Invitee[];
|
||||||
|
revokeInvite: (code: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class InviteesTable extends PureComponent<Props> {
|
||||||
|
private copyUrlRef = createRef<HTMLTextAreaElement>();
|
||||||
|
|
||||||
|
copyToClipboard = () => {
|
||||||
|
const node = this.copyUrlRef.current;
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
node.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { invitees, revokeInvite } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="filter-table form-inline">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th />
|
||||||
|
<th style={{ width: '34px' }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invitees.map((invitee, index) => {
|
||||||
|
return (
|
||||||
|
<tr key={`${invitee.id}-${index}`}>
|
||||||
|
<td>{invitee.email}</td>
|
||||||
|
<td>{invitee.name}</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<button className="btn btn-inverse btn-mini" onClick={this.copyToClipboard}>
|
||||||
|
<textarea
|
||||||
|
readOnly={true}
|
||||||
|
value={invitee.url}
|
||||||
|
style={{ position: 'absolute', right: -1000 }}
|
||||||
|
ref={this.copyUrlRef}
|
||||||
|
/>
|
||||||
|
<i className="fa fa-clipboard" /> Copy Invite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-danger btn-mini" onClick={() => revokeInvite(invitee.code)}>
|
||||||
|
<i className="fa fa-remove" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
51
public/app/features/users/UsersActionBar.test.tsx
Normal file
51
public/app/features/users/UsersActionBar.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { UsersActionBar, Props } from './UsersActionBar';
|
||||||
|
|
||||||
|
const setup = (propOverrides?: object) => {
|
||||||
|
const props: Props = {
|
||||||
|
searchQuery: '',
|
||||||
|
setUsersSearchQuery: jest.fn(),
|
||||||
|
showInvites: jest.fn(),
|
||||||
|
pendingInvitesCount: 0,
|
||||||
|
canInvite: false,
|
||||||
|
externalUserMngLinkUrl: '',
|
||||||
|
externalUserMngLinkName: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(props, propOverrides);
|
||||||
|
|
||||||
|
return shallow(<UsersActionBar {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Render', () => {
|
||||||
|
it('should render component', () => {
|
||||||
|
const wrapper = setup();
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render pending invites button', () => {
|
||||||
|
const wrapper = setup({
|
||||||
|
pendingInvitesCount: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show invite button', () => {
|
||||||
|
const wrapper = setup({
|
||||||
|
canInvite: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show external user management button', () => {
|
||||||
|
const wrapper = setup({
|
||||||
|
externalUserMngLinkUrl: 'some/url',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
80
public/app/features/users/UsersActionBar.tsx
Normal file
80
public/app/features/users/UsersActionBar.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { setUsersSearchQuery } from './state/actions';
|
||||||
|
import { getInviteesCount, getUsersSearchQuery } from './state/selectors';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
searchQuery: string;
|
||||||
|
setUsersSearchQuery: typeof setUsersSearchQuery;
|
||||||
|
showInvites: () => void;
|
||||||
|
pendingInvitesCount: number;
|
||||||
|
canInvite: boolean;
|
||||||
|
externalUserMngLinkUrl: string;
|
||||||
|
externalUserMngLinkName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsersActionBar extends PureComponent<Props> {
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
canInvite,
|
||||||
|
externalUserMngLinkName,
|
||||||
|
externalUserMngLinkUrl,
|
||||||
|
searchQuery,
|
||||||
|
pendingInvitesCount,
|
||||||
|
setUsersSearchQuery,
|
||||||
|
showInvites,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-action-bar">
|
||||||
|
<div className="gf-form gf-form--grow">
|
||||||
|
<label className="gf-form--has-input-icon">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="gf-form-input width-20"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={event => setUsersSearchQuery(event.target.value)}
|
||||||
|
placeholder="Filter by name or type"
|
||||||
|
/>
|
||||||
|
<i className="gf-form-input-icon fa fa-search" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="page-action-bar__spacer" />
|
||||||
|
{pendingInvitesCount > 0 && (
|
||||||
|
<button className="btn btn-inverse" onClick={showInvites}>
|
||||||
|
Pending Invites ({pendingInvitesCount})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canInvite && (
|
||||||
|
<a className="btn btn-success" href="org/users/invite">
|
||||||
|
<i className="fa fa-plus" />
|
||||||
|
<span>Invite</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{externalUserMngLinkUrl && (
|
||||||
|
<a className="btn btn-success" href={externalUserMngLinkUrl} target="_blank">
|
||||||
|
<i className="fa fa-external-link-square" />
|
||||||
|
{externalUserMngLinkName}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToProps(state) {
|
||||||
|
return {
|
||||||
|
searchQuery: getUsersSearchQuery(state.users),
|
||||||
|
pendingInvitesCount: getInviteesCount(state.users),
|
||||||
|
externalUserMngLinkName: state.users.externalUserMngLinkName,
|
||||||
|
externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
|
||||||
|
canInvite: state.users.canInvite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
setUsersSearchQuery,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(UsersActionBar);
|
55
public/app/features/users/UsersListPage.test.tsx
Normal file
55
public/app/features/users/UsersListPage.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { UsersListPage, Props } from './UsersListPage';
|
||||||
|
import { Invitee, NavModel, OrgUser } from 'app/types';
|
||||||
|
import { getMockUser } from './__mocks__/userMocks';
|
||||||
|
import appEvents from '../../core/app_events';
|
||||||
|
|
||||||
|
jest.mock('../../core/app_events', () => ({
|
||||||
|
emit: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const setup = (propOverrides?: object) => {
|
||||||
|
const props: Props = {
|
||||||
|
navModel: {} as NavModel,
|
||||||
|
users: [] as OrgUser[],
|
||||||
|
invitees: [] as Invitee[],
|
||||||
|
searchQuery: '',
|
||||||
|
externalUserMngInfo: '',
|
||||||
|
revokeInvite: jest.fn(),
|
||||||
|
loadInvitees: jest.fn(),
|
||||||
|
loadUsers: jest.fn(),
|
||||||
|
updateUser: jest.fn(),
|
||||||
|
removeUser: jest.fn(),
|
||||||
|
setUsersSearchQuery: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(props, propOverrides);
|
||||||
|
|
||||||
|
const wrapper = shallow(<UsersListPage {...props} />);
|
||||||
|
const instance = wrapper.instance() as UsersListPage;
|
||||||
|
|
||||||
|
return {
|
||||||
|
wrapper,
|
||||||
|
instance,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Render', () => {
|
||||||
|
it('should render component', () => {
|
||||||
|
const { wrapper } = setup();
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Functions', () => {
|
||||||
|
it('should emit show remove user modal', () => {
|
||||||
|
const { instance } = setup();
|
||||||
|
const mockUser = getMockUser();
|
||||||
|
|
||||||
|
instance.onRemoveUser(mockUser);
|
||||||
|
|
||||||
|
expect(appEvents.emit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
125
public/app/features/users/UsersListPage.tsx
Normal file
125
public/app/features/users/UsersListPage.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import { hot } from 'react-hot-loader';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||||
|
import UsersActionBar from './UsersActionBar';
|
||||||
|
import UsersTable from 'app/features/users/UsersTable';
|
||||||
|
import InviteesTable from './InviteesTable';
|
||||||
|
import { Invitee, NavModel, OrgUser } from 'app/types';
|
||||||
|
import appEvents from 'app/core/app_events';
|
||||||
|
import { loadUsers, loadInvitees, revokeInvite, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
|
||||||
|
import { getNavModel } from '../../core/selectors/navModel';
|
||||||
|
import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
navModel: NavModel;
|
||||||
|
invitees: Invitee[];
|
||||||
|
users: OrgUser[];
|
||||||
|
searchQuery: string;
|
||||||
|
externalUserMngInfo: string;
|
||||||
|
loadUsers: typeof loadUsers;
|
||||||
|
loadInvitees: typeof loadInvitees;
|
||||||
|
setUsersSearchQuery: typeof setUsersSearchQuery;
|
||||||
|
updateUser: typeof updateUser;
|
||||||
|
removeUser: typeof removeUser;
|
||||||
|
revokeInvite: typeof revokeInvite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
showInvites: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsersListPage extends PureComponent<Props, State> {
|
||||||
|
state = {
|
||||||
|
showInvites: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.fetchUsers();
|
||||||
|
this.fetchInvitees();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUsers() {
|
||||||
|
return await this.props.loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchInvitees() {
|
||||||
|
return await this.props.loadInvitees();
|
||||||
|
}
|
||||||
|
|
||||||
|
onRoleChange = (role, user) => {
|
||||||
|
const updatedUser = { ...user, role: role };
|
||||||
|
|
||||||
|
this.props.updateUser(updatedUser);
|
||||||
|
};
|
||||||
|
|
||||||
|
onRemoveUser = user => {
|
||||||
|
appEvents.emit('confirm-modal', {
|
||||||
|
title: 'Delete',
|
||||||
|
text: 'Are you sure you want to delete user ' + user.login + '?',
|
||||||
|
yesText: 'Delete',
|
||||||
|
icon: 'fa-warning',
|
||||||
|
onConfirm: () => {
|
||||||
|
this.props.removeUser(user.userId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onRevokeInvite = code => {
|
||||||
|
this.props.revokeInvite(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
showInvites = () => {
|
||||||
|
this.setState(prevState => ({
|
||||||
|
showInvites: !prevState.showInvites,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { externalUserMngInfo, invitees, navModel, users } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader model={navModel} />
|
||||||
|
<div className="page-container page-body">
|
||||||
|
<UsersActionBar showInvites={this.showInvites} />
|
||||||
|
{externalUserMngInfo && (
|
||||||
|
<div className="grafana-info-box">
|
||||||
|
<span>{externalUserMngInfo}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{this.state.showInvites ? (
|
||||||
|
<InviteesTable invitees={invitees} revokeInvite={code => this.onRevokeInvite(code)} />
|
||||||
|
) : (
|
||||||
|
<UsersTable
|
||||||
|
users={users}
|
||||||
|
onRoleChange={(role, user) => this.onRoleChange(role, user)}
|
||||||
|
onRemoveUser={user => this.onRemoveUser(user)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToProps(state) {
|
||||||
|
return {
|
||||||
|
navModel: getNavModel(state.navIndex, 'users'),
|
||||||
|
users: getUsers(state.users),
|
||||||
|
searchQuery: getUsersSearchQuery(state.users),
|
||||||
|
invitees: getInvitees(state.users),
|
||||||
|
externalUserMngInfo: state.users.externalUserMngInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
loadUsers,
|
||||||
|
loadInvitees,
|
||||||
|
setUsersSearchQuery,
|
||||||
|
updateUser,
|
||||||
|
removeUser,
|
||||||
|
revokeInvite,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UsersListPage));
|
33
public/app/features/users/UsersTable.test.tsx
Normal file
33
public/app/features/users/UsersTable.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import UsersTable, { Props } from './UsersTable';
|
||||||
|
import { OrgUser } from 'app/types';
|
||||||
|
import { getMockUsers } from './__mocks__/userMocks';
|
||||||
|
|
||||||
|
const setup = (propOverrides?: object) => {
|
||||||
|
const props: Props = {
|
||||||
|
users: [] as OrgUser[],
|
||||||
|
onRoleChange: jest.fn(),
|
||||||
|
onRemoveUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(props, propOverrides);
|
||||||
|
|
||||||
|
return shallow(<UsersTable {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Render', () => {
|
||||||
|
it('should render component', () => {
|
||||||
|
const wrapper = setup();
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render users table', () => {
|
||||||
|
const wrapper = setup({
|
||||||
|
users: getMockUsers(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
67
public/app/features/users/UsersTable.tsx
Normal file
67
public/app/features/users/UsersTable.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { SFC } from 'react';
|
||||||
|
import { OrgUser } from 'app/types';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
users: OrgUser[];
|
||||||
|
onRoleChange: (role: string, user: OrgUser) => void;
|
||||||
|
onRemoveUser: (user: OrgUser) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UsersTable: SFC<Props> = props => {
|
||||||
|
const { users, onRoleChange, onRemoveUser } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="filter-table form-inline">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>Login</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Seen</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th style={{ width: '34px' }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user, index) => {
|
||||||
|
return (
|
||||||
|
<tr key={`${user.userId}-${index}`}>
|
||||||
|
<td className="width-4 text-center">
|
||||||
|
<img className="filter-table__avatar" src={user.avatarUrl} />
|
||||||
|
</td>
|
||||||
|
<td>{user.login}</td>
|
||||||
|
<td>
|
||||||
|
<span className="ellipsis">{user.email}</span>
|
||||||
|
</td>
|
||||||
|
<td>{user.lastSeenAtAge}</td>
|
||||||
|
<td>
|
||||||
|
<div className="gf-form-select-wrapper width-12">
|
||||||
|
<select
|
||||||
|
value={user.role}
|
||||||
|
className="gf-form-input"
|
||||||
|
onChange={event => onRoleChange(event.target.value, user)}
|
||||||
|
>
|
||||||
|
{['Viewer', 'Editor', 'Admin'].map((option, index) => {
|
||||||
|
return (
|
||||||
|
<option value={option} key={`${option}-${index}`}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div onClick={() => onRemoveUser(user)} className="btn btn-danger btn-mini">
|
||||||
|
<i className="fa fa-remove" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersTable;
|
56
public/app/features/users/__mocks__/userMocks.ts
Normal file
56
public/app/features/users/__mocks__/userMocks.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export const getMockUsers = (amount: number) => {
|
||||||
|
const users = [];
|
||||||
|
|
||||||
|
for (let i = 0; i <= amount; i++) {
|
||||||
|
users.push({
|
||||||
|
avatarUrl: 'url/to/avatar',
|
||||||
|
email: `user-${i}@test.com`,
|
||||||
|
lastSeenAt: '2018-10-01',
|
||||||
|
lastSeenAtAge: '',
|
||||||
|
login: `user-${i}`,
|
||||||
|
orgId: 1,
|
||||||
|
role: 'Admin',
|
||||||
|
userId: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMockUser = () => {
|
||||||
|
return {
|
||||||
|
avatarUrl: 'url/to/avatar',
|
||||||
|
email: `user@test.com`,
|
||||||
|
lastSeenAt: '2018-10-01',
|
||||||
|
lastSeenAtAge: '',
|
||||||
|
login: `user`,
|
||||||
|
orgId: 1,
|
||||||
|
role: 'Admin',
|
||||||
|
userId: 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMockInvitees = (amount: number) => {
|
||||||
|
const invitees = [];
|
||||||
|
|
||||||
|
for (let i = 0; i <= amount; i++) {
|
||||||
|
invitees.push({
|
||||||
|
code: `asdfasdfsadf-${i}`,
|
||||||
|
createdOn: '2018-10-02',
|
||||||
|
email: `invitee-${i}@test.com`,
|
||||||
|
emailSent: true,
|
||||||
|
emailSentOn: '2018-10-02',
|
||||||
|
id: i,
|
||||||
|
invitedByEmail: 'admin@grafana.com',
|
||||||
|
invitedByLogin: 'admin',
|
||||||
|
invitedByName: 'admin',
|
||||||
|
name: `invitee-${i}`,
|
||||||
|
orgId: 1,
|
||||||
|
role: 'viewer',
|
||||||
|
status: 'not accepted',
|
||||||
|
url: `localhost/invite/$${i}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return invitees;
|
||||||
|
};
|
@@ -0,0 +1,318 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Render should render component 1`] = `
|
||||||
|
<table
|
||||||
|
className="filter-table form-inline"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th />
|
||||||
|
<th
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"width": "34px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody />
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Render should render invitees 1`] = `
|
||||||
|
<table
|
||||||
|
className="filter-table form-inline"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th />
|
||||||
|
<th
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"width": "34px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
key="0-0"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
invitee-0@test.com
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
invitee-0
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-inverse btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
readOnly={true}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"position": "absolute",
|
||||||
|
"right": -1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value="localhost/invite/$0"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="fa fa-clipboard"
|
||||||
|
/>
|
||||||
|
Copy Invite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="1-1"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
invitee-1@test.com
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
invitee-1
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-inverse btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
readOnly={true}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"position": "absolute",
|
||||||
|
"right": -1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value="localhost/invite/$1"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="fa fa-clipboard"
|
||||||
|
/>
|
||||||
|
Copy Invite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="2-2"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
invitee-2@test.com
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
invitee-2
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-inverse btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
readOnly={true}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"position": "absolute",
|
||||||
|
"right": -1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value="localhost/invite/$2"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="fa fa-clipboard"
|
||||||
|
/>
|
||||||
|
Copy Invite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="3-3"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
invitee-3@test.com
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
invitee-3
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-inverse btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
readOnly={true}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"position": "absolute",
|
||||||
|
"right": -1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value="localhost/invite/$3"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="fa fa-clipboard"
|
||||||
|
/>
|
||||||
|
Copy Invite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="4-4"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
invitee-4@test.com
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
invitee-4
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-inverse btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
readOnly={true}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"position": "absolute",
|
||||||
|
"right": -1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value="localhost/invite/$4"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="fa fa-clipboard"
|
||||||
|
/>
|
||||||
|
Copy Invite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="5-5"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
invitee-5@test.com
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
invitee-5
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="text-right"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-inverse btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
readOnly={true}
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"position": "absolute",
|
||||||
|
"right": -1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value="localhost/invite/$5"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="fa fa-clipboard"
|
||||||
|
/>
|
||||||
|
Copy Invite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
@@ -0,0 +1,141 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Render should render component 1`] = `
|
||||||
|
<div
|
||||||
|
className="page-action-bar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="gf-form gf-form--grow"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="gf-form--has-input-icon"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="gf-form-input width-20"
|
||||||
|
onChange={[Function]}
|
||||||
|
placeholder="Filter by name or type"
|
||||||
|
type="text"
|
||||||
|
value=""
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="gf-form-input-icon fa fa-search"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="page-action-bar__spacer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Render should render pending invites button 1`] = `
|
||||||
|
<div
|
||||||
|
className="page-action-bar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="gf-form gf-form--grow"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="gf-form--has-input-icon"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="gf-form-input width-20"
|
||||||
|
onChange={[Function]}
|
||||||
|
placeholder="Filter by name or type"
|
||||||
|
type="text"
|
||||||
|
value=""
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="gf-form-input-icon fa fa-search"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="page-action-bar__spacer"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-inverse"
|
||||||
|
onClick={[MockFunction]}
|
||||||
|
>
|
||||||
|
Pending Invites (
|
||||||
|
5
|
||||||
|
)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Render should show external user management button 1`] = `
|
||||||
|
<div
|
||||||
|
className="page-action-bar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="gf-form gf-form--grow"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="gf-form--has-input-icon"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="gf-form-input width-20"
|
||||||
|
onChange={[Function]}
|
||||||
|
placeholder="Filter by name or type"
|
||||||
|
type="text"
|
||||||
|
value=""
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="gf-form-input-icon fa fa-search"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="page-action-bar__spacer"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
className="btn btn-success"
|
||||||
|
href="some/url"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-external-link-square"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Render should show invite button 1`] = `
|
||||||
|
<div
|
||||||
|
className="page-action-bar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="gf-form gf-form--grow"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="gf-form--has-input-icon"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="gf-form-input width-20"
|
||||||
|
onChange={[Function]}
|
||||||
|
placeholder="Filter by name or type"
|
||||||
|
type="text"
|
||||||
|
value=""
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="gf-form-input-icon fa fa-search"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="page-action-bar__spacer"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
className="btn btn-success"
|
||||||
|
href="org/users/invite"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-plus"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Invite
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
@@ -0,0 +1,21 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Render should render component 1`] = `
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
model={Object {}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="page-container page-body"
|
||||||
|
>
|
||||||
|
<Connect(UsersActionBar)
|
||||||
|
showInvites={[Function]}
|
||||||
|
/>
|
||||||
|
<UsersTable
|
||||||
|
onRemoveUser={[Function]}
|
||||||
|
onRoleChange={[Function]}
|
||||||
|
users={Array []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
444
public/app/features/users/__snapshots__/UsersTable.test.tsx.snap
Normal file
444
public/app/features/users/__snapshots__/UsersTable.test.tsx.snap
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Render should render component 1`] = `
|
||||||
|
<table
|
||||||
|
className="filter-table form-inline"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>
|
||||||
|
Login
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Seen
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"width": "34px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody />
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Render should render users table 1`] = `
|
||||||
|
<table
|
||||||
|
className="filter-table form-inline"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>
|
||||||
|
Login
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Seen
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"width": "34px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
key="0-0"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="width-4 text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="filter-table__avatar"
|
||||||
|
src="url/to/avatar"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
user-0
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="ellipsis"
|
||||||
|
>
|
||||||
|
user-0@test.com
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="gf-form-select-wrapper width-12"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="gf-form-input"
|
||||||
|
onChange={[Function]}
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
key="Viewer-0"
|
||||||
|
value="Viewer"
|
||||||
|
>
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Editor-1"
|
||||||
|
value="Editor"
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Admin-2"
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="1-1"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="width-4 text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="filter-table__avatar"
|
||||||
|
src="url/to/avatar"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
user-1
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="ellipsis"
|
||||||
|
>
|
||||||
|
user-1@test.com
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="gf-form-select-wrapper width-12"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="gf-form-input"
|
||||||
|
onChange={[Function]}
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
key="Viewer-0"
|
||||||
|
value="Viewer"
|
||||||
|
>
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Editor-1"
|
||||||
|
value="Editor"
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Admin-2"
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="2-2"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="width-4 text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="filter-table__avatar"
|
||||||
|
src="url/to/avatar"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
user-2
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="ellipsis"
|
||||||
|
>
|
||||||
|
user-2@test.com
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="gf-form-select-wrapper width-12"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="gf-form-input"
|
||||||
|
onChange={[Function]}
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
key="Viewer-0"
|
||||||
|
value="Viewer"
|
||||||
|
>
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Editor-1"
|
||||||
|
value="Editor"
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Admin-2"
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="3-3"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="width-4 text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="filter-table__avatar"
|
||||||
|
src="url/to/avatar"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
user-3
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="ellipsis"
|
||||||
|
>
|
||||||
|
user-3@test.com
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="gf-form-select-wrapper width-12"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="gf-form-input"
|
||||||
|
onChange={[Function]}
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
key="Viewer-0"
|
||||||
|
value="Viewer"
|
||||||
|
>
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Editor-1"
|
||||||
|
value="Editor"
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Admin-2"
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="4-4"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="width-4 text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="filter-table__avatar"
|
||||||
|
src="url/to/avatar"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
user-4
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="ellipsis"
|
||||||
|
>
|
||||||
|
user-4@test.com
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="gf-form-select-wrapper width-12"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="gf-form-input"
|
||||||
|
onChange={[Function]}
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
key="Viewer-0"
|
||||||
|
value="Viewer"
|
||||||
|
>
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Editor-1"
|
||||||
|
value="Editor"
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Admin-2"
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
key="5-5"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="width-4 text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="filter-table__avatar"
|
||||||
|
src="url/to/avatar"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
user-5
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="ellipsis"
|
||||||
|
>
|
||||||
|
user-5@test.com
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="gf-form-select-wrapper width-12"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="gf-form-input"
|
||||||
|
onChange={[Function]}
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
key="Viewer-0"
|
||||||
|
value="Viewer"
|
||||||
|
>
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Editor-1"
|
||||||
|
value="Editor"
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
key="Admin-2"
|
||||||
|
value="Admin"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className="btn btn-danger btn-mini"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fa fa-remove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
79
public/app/features/users/state/actions.ts
Normal file
79
public/app/features/users/state/actions.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { ThunkAction } from 'redux-thunk';
|
||||||
|
import { StoreState } from '../../../types';
|
||||||
|
import { getBackendSrv } from '../../../core/services/backend_srv';
|
||||||
|
import { Invitee, OrgUser } from 'app/types';
|
||||||
|
|
||||||
|
export enum ActionTypes {
|
||||||
|
LoadUsers = 'LOAD_USERS',
|
||||||
|
LoadInvitees = 'LOAD_INVITEES',
|
||||||
|
SetUsersSearchQuery = 'SET_USERS_SEARCH_QUERY',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadUsersAction {
|
||||||
|
type: ActionTypes.LoadUsers;
|
||||||
|
payload: OrgUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadInviteesAction {
|
||||||
|
type: ActionTypes.LoadInvitees;
|
||||||
|
payload: Invitee[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetUsersSearchQueryAction {
|
||||||
|
type: ActionTypes.SetUsersSearchQuery;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersLoaded = (users: OrgUser[]): LoadUsersAction => ({
|
||||||
|
type: ActionTypes.LoadUsers,
|
||||||
|
payload: users,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteesLoaded = (invitees: Invitee[]): LoadInviteesAction => ({
|
||||||
|
type: ActionTypes.LoadInvitees,
|
||||||
|
payload: invitees,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setUsersSearchQuery = (query: string): SetUsersSearchQueryAction => ({
|
||||||
|
type: ActionTypes.SetUsersSearchQuery,
|
||||||
|
payload: query,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Action = LoadUsersAction | SetUsersSearchQueryAction | LoadInviteesAction;
|
||||||
|
|
||||||
|
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
|
||||||
|
|
||||||
|
export function loadUsers(): ThunkResult<void> {
|
||||||
|
return async dispatch => {
|
||||||
|
const users = await getBackendSrv().get('/api/org/users');
|
||||||
|
dispatch(usersLoaded(users));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadInvitees(): ThunkResult<void> {
|
||||||
|
return async dispatch => {
|
||||||
|
const invitees = await getBackendSrv().get('/api/org/invites');
|
||||||
|
dispatch(inviteesLoaded(invitees));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(user: OrgUser): ThunkResult<void> {
|
||||||
|
return async dispatch => {
|
||||||
|
await getBackendSrv().patch(`/api/org/users/${user.userId}`, user);
|
||||||
|
dispatch(loadUsers());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeUser(userId: number): ThunkResult<void> {
|
||||||
|
return async dispatch => {
|
||||||
|
await getBackendSrv().delete(`/api/org/users/${userId}`);
|
||||||
|
dispatch(loadUsers());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeInvite(code: string): ThunkResult<void> {
|
||||||
|
return async dispatch => {
|
||||||
|
await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
|
||||||
|
dispatch(loadInvitees());
|
||||||
|
};
|
||||||
|
}
|
32
public/app/features/users/state/reducers.ts
Normal file
32
public/app/features/users/state/reducers.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Invitee, OrgUser, UsersState } from 'app/types';
|
||||||
|
import { Action, ActionTypes } from './actions';
|
||||||
|
import config from '../../../core/config';
|
||||||
|
|
||||||
|
export const initialState: UsersState = {
|
||||||
|
invitees: [] as Invitee[],
|
||||||
|
users: [] as OrgUser[],
|
||||||
|
searchQuery: '',
|
||||||
|
canInvite: !config.disableLoginForm && !config.externalUserMngLinkName,
|
||||||
|
externalUserMngInfo: config.externalUserMngInfo,
|
||||||
|
externalUserMngLinkName: config.externalUserMngLinkName,
|
||||||
|
externalUserMngLinkUrl: config.externalUserMngLinkUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usersReducer = (state = initialState, action: Action): UsersState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionTypes.LoadUsers:
|
||||||
|
return { ...state, users: action.payload };
|
||||||
|
|
||||||
|
case ActionTypes.LoadInvitees:
|
||||||
|
return { ...state, invitees: action.payload };
|
||||||
|
|
||||||
|
case ActionTypes.SetUsersSearchQuery:
|
||||||
|
return { ...state, searchQuery: action.payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
users: usersReducer,
|
||||||
|
};
|
18
public/app/features/users/state/selectors.ts
Normal file
18
public/app/features/users/state/selectors.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const getUsers = state => {
|
||||||
|
const regex = new RegExp(state.searchQuery, 'i');
|
||||||
|
|
||||||
|
return state.users.filter(user => {
|
||||||
|
return regex.test(user.login) || regex.test(user.email);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInvitees = state => {
|
||||||
|
const regex = new RegExp(state.searchQuery, 'i');
|
||||||
|
|
||||||
|
return state.invitees.filter(invitee => {
|
||||||
|
return regex.test(invitee.name) || regex.test(invitee.email);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInviteesCount = state => state.invitees.length;
|
||||||
|
export const getUsersSearchQuery = state => state.searchQuery;
|
@@ -10,6 +10,7 @@ import PluginListPage from 'app/features/plugins/PluginListPage';
|
|||||||
import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
|
import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
|
||||||
import FolderPermissions from 'app/features/folders/FolderPermissions';
|
import FolderPermissions from 'app/features/folders/FolderPermissions';
|
||||||
import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage';
|
import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage';
|
||||||
|
import UsersListPage from 'app/features/users/UsersListPage';
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
export function setupAngularRoutes($routeProvider, $locationProvider) {
|
export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||||
@@ -133,9 +134,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
|||||||
controller: 'NewOrgCtrl',
|
controller: 'NewOrgCtrl',
|
||||||
})
|
})
|
||||||
.when('/org/users', {
|
.when('/org/users', {
|
||||||
templateUrl: 'public/app/features/org/partials/orgUsers.html',
|
template: '<react-container />',
|
||||||
controller: 'OrgUsersCtrl',
|
resolve: {
|
||||||
controllerAs: 'ctrl',
|
component: () => UsersListPage,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.when('/org/users/invite', {
|
.when('/org/users/invite', {
|
||||||
templateUrl: 'public/app/features/org/partials/invite.html',
|
templateUrl: 'public/app/features/org/partials/invite.html',
|
||||||
|
@@ -9,6 +9,7 @@ import foldersReducers from 'app/features/folders/state/reducers';
|
|||||||
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
||||||
import pluginReducers from 'app/features/plugins/state/reducers';
|
import pluginReducers from 'app/features/plugins/state/reducers';
|
||||||
import dataSourcesReducers from 'app/features/datasources/state/reducers';
|
import dataSourcesReducers from 'app/features/datasources/state/reducers';
|
||||||
|
import usersReducers from 'app/features/users/state/reducers';
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
...sharedReducers,
|
...sharedReducers,
|
||||||
@@ -19,6 +20,7 @@ const rootReducer = combineReducers({
|
|||||||
...dashboardReducers,
|
...dashboardReducers,
|
||||||
...pluginReducers,
|
...pluginReducers,
|
||||||
...dataSourcesReducers,
|
...dataSourcesReducers,
|
||||||
|
...usersReducers,
|
||||||
});
|
});
|
||||||
|
|
||||||
export let store;
|
export let store;
|
||||||
|
@@ -6,7 +6,7 @@ import { FolderDTO, FolderState, FolderInfo } from './folders';
|
|||||||
import { DashboardState } from './dashboard';
|
import { DashboardState } from './dashboard';
|
||||||
import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
|
import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
|
||||||
import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
|
import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
|
||||||
import { User } from './user';
|
import { Invitee, OrgUser, User, UsersState } from './user';
|
||||||
import { DataSource, DataSourcesState } from './datasources';
|
import { DataSource, DataSourcesState } from './datasources';
|
||||||
import { PluginMeta, Plugin, PluginsState } from './plugins';
|
import { PluginMeta, Plugin, PluginsState } from './plugins';
|
||||||
|
|
||||||
@@ -38,10 +38,13 @@ export {
|
|||||||
ApiKey,
|
ApiKey,
|
||||||
ApiKeysState,
|
ApiKeysState,
|
||||||
NewApiKey,
|
NewApiKey,
|
||||||
User,
|
|
||||||
Plugin,
|
Plugin,
|
||||||
PluginsState,
|
PluginsState,
|
||||||
DataSourcesState,
|
DataSourcesState,
|
||||||
|
Invitee,
|
||||||
|
OrgUser,
|
||||||
|
User,
|
||||||
|
UsersState,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface StoreState {
|
export interface StoreState {
|
||||||
@@ -52,4 +55,6 @@ export interface StoreState {
|
|||||||
team: TeamState;
|
team: TeamState;
|
||||||
folder: FolderState;
|
folder: FolderState;
|
||||||
dashboard: DashboardState;
|
dashboard: DashboardState;
|
||||||
|
dataSources: DataSourcesState;
|
||||||
|
users: UsersState;
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,44 @@
|
|||||||
export interface User {
|
export interface OrgUser {
|
||||||
|
avatarUrl: string;
|
||||||
|
email: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
lastSeenAtAge: string;
|
||||||
|
login: string;
|
||||||
|
orgId: number;
|
||||||
|
role: string;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
login: string;
|
login: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Invitee {
|
||||||
|
code: string;
|
||||||
|
createdOn: string;
|
||||||
|
email: string;
|
||||||
|
emailSent: boolean;
|
||||||
|
emailSentOn: string;
|
||||||
|
id: number;
|
||||||
|
invitedByEmail: string;
|
||||||
|
invitedByLogin: string;
|
||||||
|
invitedByName: string;
|
||||||
|
name: string;
|
||||||
|
orgId: number;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsersState {
|
||||||
|
users: OrgUser[];
|
||||||
|
invitees: Invitee[];
|
||||||
|
searchQuery: string;
|
||||||
|
canInvite: boolean;
|
||||||
|
externalUserMngLinkUrl: string;
|
||||||
|
externalUserMngLinkName: string;
|
||||||
|
externalUserMngInfo: string;
|
||||||
|
}
|
||||||
|
37
public/app/types/users.ts
Normal file
37
public/app/types/users.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface Invitee {
|
||||||
|
code: string;
|
||||||
|
createdOn: string;
|
||||||
|
email: string;
|
||||||
|
emailSent: boolean;
|
||||||
|
emailSentOn: string;
|
||||||
|
id: number;
|
||||||
|
invitedByEmail: string;
|
||||||
|
invitedByLogin: string;
|
||||||
|
invitedByName: string;
|
||||||
|
name: string;
|
||||||
|
orgId: number;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
avatarUrl: string;
|
||||||
|
email: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
lastSeenAtAge: string;
|
||||||
|
login: string;
|
||||||
|
orgId: number;
|
||||||
|
role: string;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsersState {
|
||||||
|
users: User[];
|
||||||
|
invitees: Invitee[];
|
||||||
|
searchQuery: string;
|
||||||
|
canInvite: boolean;
|
||||||
|
externalUserMngLinkUrl: string;
|
||||||
|
externalUserMngLinkName: string;
|
||||||
|
externalUserMngInfo: string;
|
||||||
|
}
|
Reference in New Issue
Block a user