Commit afdec3d1 by Torkel Ödegaard Committed by GitHub

Revert "Revert "Org users to react""

parent d59c86cc
import React from 'react';
import { shallow } from 'enzyme';
import { DataSourcesActionBar, Props } from './DataSourcesActionBar';
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
import OrgActionBar, { Props } from './OrgActionBar';
const setup = (propOverrides?: object) => {
const props: Props = {
layoutMode: LayoutModes.Grid,
searchQuery: '',
setDataSourcesLayoutMode: jest.fn(),
setDataSourcesSearchQuery: jest.fn(),
setSearchQuery: jest.fn(),
linkButton: { href: 'some/url', title: 'test' },
};
return shallow(<DataSourcesActionBar {...props} />);
Object.assign(props, propOverrides);
return shallow(<OrgActionBar {...props} />);
};
describe('Render', () => {
......
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';
import LayoutSelector, { LayoutMode } from '../LayoutSelector/LayoutSelector';
export interface Props {
searchQuery: string;
layoutMode: LayoutMode;
setLayoutMode: typeof setLayoutMode;
setPluginsSearchQuery: typeof setPluginsSearchQuery;
layoutMode?: LayoutMode;
setLayoutMode?: (mode: LayoutMode) => {};
setSearchQuery: (value: string) => {};
linkButton: { href: string; title: string };
}
export class PluginActionBar extends PureComponent<Props> {
onSearchQueryChange = event => {
this.props.setPluginsSearchQuery(event.target.value);
};
export default class OrgActionBar extends PureComponent<Props> {
render() {
const { searchQuery, layoutMode, setLayoutMode } = this.props;
const { searchQuery, layoutMode, setLayoutMode, linkButton, setSearchQuery } = this.props;
return (
<div className="page-action-bar">
......@@ -27,7 +21,7 @@ export class PluginActionBar extends PureComponent<Props> {
type="text"
className="gf-form-input width-20"
value={searchQuery}
onChange={this.onSearchQueryChange}
onChange={event => setSearchQuery(event.target.value)}
placeholder="Filter by name or type"
/>
<i className="gf-form-input-icon fa fa-search" />
......@@ -35,28 +29,10 @@ export class PluginActionBar extends PureComponent<Props> {
<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 className="btn btn-success" href={linkButton.href} target="_blank">
{linkButton.title}
</a>
</div>
);
}
}
function mapStateToProps(state) {
return {
searchQuery: getPluginsSearchQuery(state.plugins),
layoutMode: getLayoutMode(state.plugins),
};
}
const mapDispatchToProps = {
setPluginsSearchQuery,
setLayoutMode,
};
export default connect(mapStateToProps, mapDispatchToProps)(PluginActionBar);
......@@ -22,7 +22,6 @@ exports[`Render should render component 1`] = `
/>
</label>
<LayoutSelector
mode="grid"
onLayoutModeChanged={[Function]}
/>
</div>
......@@ -31,10 +30,10 @@ exports[`Render should render component 1`] = `
/>
<a
className="btn btn-success"
href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
href="some/url"
target="_blank"
>
Find more plugins on Grafana.com
test
</a>
</div>
`;
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(),
navModel: {} as NavModel,
dataSourcesCount: 0,
searchQuery: '',
setDataSourcesSearchQuery: jest.fn(),
setDataSourcesLayoutMode: jest.fn(),
};
Object.assign(props, propOverrides);
......
......@@ -2,21 +2,29 @@ import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
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 { loadDataSources } from './state/actions';
import { getDataSources, getDataSourcesCount, getDataSourcesLayoutMode } from './state/selectors';
import { getNavModel } from '../../core/selectors/navModel';
import { DataSource, NavModel } from 'app/types';
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 {
navModel: NavModel;
dataSources: DataSource[];
dataSourcesCount: number;
layoutMode: LayoutMode;
searchQuery: string;
loadDataSources: typeof loadDataSources;
setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
}
const emptyListModel = {
......@@ -40,7 +48,20 @@ export class DataSourcesListPage extends PureComponent<Props> {
}
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 (
<div>
......@@ -50,7 +71,14 @@ export class DataSourcesListPage extends PureComponent<Props> {
<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" />,
]
)}
......@@ -66,11 +94,14 @@ function mapStateToProps(state) {
dataSources: getDataSources(state.dataSources),
layoutMode: getDataSourcesLayoutMode(state.dataSources),
dataSourcesCount: getDataSourcesCount(state.dataSources),
searchQuery: getDataSourcesSearchQuery(state.dataSources),
};
}
const mapDispatchToProps = {
loadDataSources,
setDataSourcesSearchQuery,
setDataSourcesLayoutMode,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage));
// 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
className="page-container page-body"
>
<Connect(DataSourcesActionBar)
<OrgActionBar
key="action-bar"
layoutMode="grid"
linkButton={
Object {
"href": "datasources/new",
"title": "Add data source",
}
}
searchQuery=""
setLayoutMode={[Function]}
setSearchQuery={[Function]}
/>
<DataSourcesList
dataSources={
......
import './org_users_ctrl';
import './profile_ctrl';
import './org_users_ctrl';
import './select_org_ctrl';
import './change_password_ctrl';
import './new_org_ctrl';
......
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);
<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>
&nbsp;
</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>
......@@ -8,6 +8,9 @@ const setup = (propOverrides?: object) => {
const props: Props = {
navModel: {} as NavModel,
plugins: [] as Plugin[],
searchQuery: '',
setPluginsSearchQuery: jest.fn(),
setPluginsLayoutMode: jest.fn(),
layoutMode: LayoutModes.Grid,
loadPlugins: jest.fn(),
};
......
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import PageHeader from '../../core/components/PageHeader/PageHeader';
import PluginActionBar from './PluginActionBar';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
import PluginList from './PluginList';
import { NavModel, Plugin } from '../../types';
import { loadPlugins } from './state/actions';
import { NavModel, Plugin } from 'app/types';
import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
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';
export interface Props {
navModel: NavModel;
plugins: Plugin[];
layoutMode: LayoutMode;
searchQuery: string;
loadPlugins: typeof loadPlugins;
setPluginsLayoutMode: typeof setPluginsLayoutMode;
setPluginsSearchQuery: typeof setPluginsSearchQuery;
}
export class PluginListPage extends PureComponent<Props> {
......@@ -27,13 +30,23 @@ export class PluginListPage extends PureComponent<Props> {
}
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 (
<div>
<PageHeader model={navModel} />
<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} />}
</div>
</div>
......@@ -46,11 +59,14 @@ function mapStateToProps(state) {
navModel: getNavModel(state.navIndex, 'plugins'),
plugins: getPlugins(state.plugins),
layoutMode: getLayoutMode(state.plugins),
searchQuery: getPluginsSearchQuery(state.plugins),
};
}
const mapDispatchToProps = {
loadPlugins,
setPluginsLayoutMode,
setPluginsSearchQuery,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));
......@@ -8,7 +8,18 @@ exports[`Render should render component 1`] = `
<div
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
layoutMode="grid"
plugins={Array []}
......
......@@ -24,7 +24,7 @@ export interface SetLayoutModeAction {
payload: LayoutMode;
}
export const setLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
export const setPluginsLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
type: ActionTypes.SetLayoutMode,
payload: mode,
});
......
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();
});
});
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>
&nbsp;
</td>
<td>
<button className="btn btn-danger btn-mini" onClick={() => revokeInvite(invitee.code)}>
<i className="fa fa-remove" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
}
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();
});
});
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);
import React from 'react';
import { shallow } from 'enzyme';
import { PluginActionBar, Props } from './PluginActionBar';
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
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: '',
layoutMode: LayoutModes.Grid,
setLayoutMode: jest.fn(),
setPluginsSearchQuery: jest.fn(),
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(<PluginActionBar {...props} />);
const instance = wrapper.instance() as PluginActionBar;
const wrapper = shallow(<UsersListPage {...props} />);
const instance = wrapper.instance() as UsersListPage;
return {
wrapper,
......@@ -29,3 +42,14 @@ describe('Render', () => {
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();
});
});
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));
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();
});
});
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;
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;
};
// 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>
`;
// 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>
`;
// 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>
`;
// 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>
`;
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());
};
}
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,
};
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 FolderPermissions from 'app/features/folders/FolderPermissions';
import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage';
import UsersListPage from 'app/features/users/UsersListPage';
/** @ngInject */
export function setupAngularRoutes($routeProvider, $locationProvider) {
......@@ -133,9 +134,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
controller: 'NewOrgCtrl',
})
.when('/org/users', {
templateUrl: 'public/app/features/org/partials/orgUsers.html',
controller: 'OrgUsersCtrl',
controllerAs: 'ctrl',
template: '<react-container />',
resolve: {
component: () => UsersListPage,
},
})
.when('/org/users/invite', {
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 pluginReducers from 'app/features/plugins/state/reducers';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
const rootReducer = combineReducers({
...sharedReducers,
......@@ -19,6 +20,7 @@ const rootReducer = combineReducers({
...dashboardReducers,
...pluginReducers,
...dataSourcesReducers,
...usersReducers,
});
export let store;
......
......@@ -6,7 +6,7 @@ import { FolderDTO, FolderState, FolderInfo } from './folders';
import { DashboardState } from './dashboard';
import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
import { User } from './user';
import { Invitee, OrgUser, User, UsersState } from './user';
import { DataSource, DataSourcesState } from './datasources';
import { PluginMeta, Plugin, PluginsState } from './plugins';
......@@ -38,10 +38,13 @@ export {
ApiKey,
ApiKeysState,
NewApiKey,
User,
Plugin,
PluginsState,
DataSourcesState,
Invitee,
OrgUser,
User,
UsersState,
};
export interface StoreState {
......@@ -52,4 +55,6 @@ export interface StoreState {
team: TeamState;
folder: FolderState;
dashboard: DashboardState;
dataSources: DataSourcesState;
users: UsersState;
}
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;
label: string;
avatarUrl: 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;
}
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;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment