Merge branch 'master' into 9587_annotation_tags_wih_temp_var

* master:
  fix theme parameter not working problem while prefer theme set to light (#13232)
  fix: added type export to fix failing test
  fix: fixed typescript test error
  another circleci fix
  Another circleci fix
  changed gometalinter to use github master
  commented out metalinter as gopkg is having issues
  Fix prometheus label filtering for comparison queries (#13213)
  Upgrade react and enzyme (#13224)
  Teams page replace mobx (#13219)
  upgrade of typescript and tslint and jest (#13223)
  fix nil pointer dereference (#13221)
This commit is contained in:
bergquist
2018-09-12 09:23:54 +02:00
56 changed files with 4162 additions and 2207 deletions

View File

@@ -81,7 +81,7 @@ jobs:
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
- run: 'go get -u gopkg.in/alecthomas/gometalinter.v2'
- run: 'go get -u github.com/alecthomas/gometalinter'
- run: 'go get -u github.com/tsenart/deadcode'
- run: 'go get -u github.com/gordonklaus/ineffassign'
- run: 'go get -u github.com/opennota/check/cmd/structcheck'
@@ -89,7 +89,7 @@ jobs:
- run: 'go get -u github.com/opennota/check/cmd/varcheck'
- run:
name: run linters
command: 'gometalinter.v2 --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=ineffassign --enable=structcheck --enable=unconvert --enable=varcheck ./...'
command: 'gometalinter --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=ineffassign --enable=structcheck --enable=unconvert --enable=varcheck ./...'
- run:
name: run go vet
command: 'go vet ./pkg/...'

View File

@@ -1,13 +1,8 @@
module.exports = {
verbose: false,
"globals": {
"ts-jest": {
"tsConfigFile": "tsconfig.json"
}
},
"transform": {
"^.+\\.tsx?$": "<rootDir>/node_modules/ts-jest/preprocessor.js"
"^.+\\.(ts|tsx)$": "ts-jest"
},
"moduleDirectories": ["node_modules", "public"],
"roots": [

View File

@@ -11,12 +11,12 @@
},
"devDependencies": {
"@types/d3": "^4.10.1",
"@types/enzyme": "^2.8.9",
"@types/enzyme": "^3.1.13",
"@types/jest": "^21.1.4",
"@types/node": "^8.0.31",
"@types/react": "^16.0.25",
"@types/react": "^16.4.14",
"@types/react-custom-scrollbars": "^4.0.5",
"@types/react-dom": "^16.0.3",
"@types/react-dom": "^16.0.7",
"angular-mocks": "1.6.6",
"autoprefixer": "^6.4.0",
"axios": "^0.17.1",
@@ -26,15 +26,15 @@
"babel-preset-es2015": "^6.24.1",
"clean-webpack-plugin": "^0.1.19",
"css-loader": "^0.28.7",
"enzyme": "^3.1.0",
"enzyme-adapter-react-16": "^1.0.1",
"enzyme-to-json": "^3.3.0",
"enzyme": "^3.6.0",
"enzyme-adapter-react-16": "^1.5.0",
"enzyme-to-json": "^3.3.4",
"es6-promise": "^3.0.2",
"es6-shim": "^0.35.3",
"expect.js": "~0.2.0",
"expose-loader": "^0.7.3",
"file-loader": "^1.1.11",
"fork-ts-checker-webpack-plugin": "^0.4.2",
"fork-ts-checker-webpack-plugin": "^0.4.9",
"gaze": "^1.1.2",
"glob": "~7.0.0",
"grunt": "1.0.1",
@@ -56,7 +56,7 @@
"html-webpack-harddisk-plugin": "^0.2.0",
"html-webpack-plugin": "^3.2.0",
"husky": "^0.14.3",
"jest": "^22.0.4",
"jest": "^23.6.0",
"lint-staged": "^6.0.0",
"load-grunt-tasks": "3.5.2",
"mini-css-extract-plugin": "^0.4.0",
@@ -72,20 +72,20 @@
"postcss-loader": "^2.0.6",
"postcss-reporter": "^5.0.0",
"prettier": "1.9.2",
"react-hot-loader": "^4.2.0",
"react-test-renderer": "^16.0.0",
"react-hot-loader": "^4.3.6",
"react-test-renderer": "^16.5.0",
"sass-lint": "^1.10.2",
"sass-loader": "^7.0.1",
"sinon": "1.17.6",
"style-loader": "^0.21.0",
"systemjs": "0.20.19",
"systemjs-plugin-css": "^0.1.36",
"ts-jest": "^22.4.6",
"ts-loader": "^4.3.0",
"ts-jest": "^23.1.4",
"ts-loader": "^5.1.0",
"tslib": "^1.9.3",
"tslint": "^5.8.0",
"tslint-loader": "^3.5.3",
"typescript": "^2.6.2",
"typescript": "^3.0.3",
"uglifyjs-webpack-plugin": "^1.2.7",
"webpack": "^4.8.0",
"webpack-bundle-analyzer": "^2.9.0",
@@ -133,6 +133,7 @@
"angular-native-dragdrop": "1.2.2",
"angular-route": "1.6.6",
"angular-sanitize": "1.6.6",
"babel-jest": "^23.6.0",
"babel-polyfill": "^6.26.0",
"baron": "^3.0.3",
"brace": "^0.10.0",
@@ -152,11 +153,11 @@
"mousetrap": "^1.6.0",
"mousetrap-global-bind": "^1.1.0",
"prismjs": "^1.6.0",
"prop-types": "^15.6.0",
"prop-types": "^15.6.2",
"rc-cascader": "^0.14.0",
"react": "^16.2.0",
"react": "^16.5.0",
"react-custom-scrollbars": "^4.2.1",
"react-dom": "^16.2.0",
"react-dom": "^16.5.0",
"react-grid-layout": "0.16.6",
"react-highlight-words": "^0.10.0",
"react-popper": "^0.7.5",

View File

@@ -91,6 +91,9 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
if themeURLParam == "light" {
data.User.LightTheme = true
data.Theme = "light"
} else if themeURLParam == "dark" {
data.User.LightTheme = false
data.Theme = "dark"
}
if hasEditPermissionInFoldersQuery.Result {

View File

@@ -466,6 +466,9 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context,
return nil, errors.New("invalid attribute path")
}
v = v.FieldByName(key)
if !v.IsValid() {
return nil, errors.New("invalid attribute path")
}
}
if attr, ok := v.Interface().(*string); ok {
data = *attr

View File

@@ -1,77 +0,0 @@
import React from 'react';
import _ from 'lodash';
import { hot } from 'react-hot-loader';
import { inject, observer } from 'mobx-react';
import config from 'app/core/config';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import { NavStore } from 'app/stores/NavStore/NavStore';
import { TeamsStore, Team } from 'app/stores/TeamsStore/TeamsStore';
import { ViewStore } from 'app/stores/ViewStore/ViewStore';
import TeamMembers from './TeamMembers';
import TeamSettings from './TeamSettings';
import TeamGroupSync from './TeamGroupSync';
interface Props {
nav: typeof NavStore.Type;
teams: typeof TeamsStore.Type;
view: typeof ViewStore.Type;
}
@inject('nav', 'teams', 'view')
@observer
export class TeamPages extends React.Component<Props, any> {
isSyncEnabled: boolean;
currentPage: string;
constructor(props) {
super(props);
this.isSyncEnabled = config.buildInfo.isEnterprise;
this.currentPage = this.getCurrentPage();
this.loadTeam();
}
async loadTeam() {
const { teams, nav, view } = this.props;
await teams.loadById(view.routeParams.get('id'));
nav.initTeamPage(this.getCurrentTeam(), this.currentPage, this.isSyncEnabled);
}
getCurrentTeam(): Team {
const { teams, view } = this.props;
return teams.map.get(view.routeParams.get('id'));
}
getCurrentPage() {
const pages = ['members', 'settings', 'groupsync'];
const currentPage = this.props.view.routeParams.get('page');
return _.includes(pages, currentPage) ? currentPage : pages[0];
}
render() {
const { nav } = this.props;
const currentTeam = this.getCurrentTeam();
if (!nav.main) {
return null;
}
return (
<div>
<PageHeader model={nav as any} />
{currentTeam && (
<div className="page-container page-body">
{this.currentPage === 'members' && <TeamMembers team={currentTeam} />}
{this.currentPage === 'settings' && <TeamSettings team={currentTeam} />}
{this.currentPage === 'groupsync' && this.isSyncEnabled && <TeamGroupSync team={currentTeam} />}
</div>
)}
</div>
);
}
}
export default hot(module)(TeamPages);

View File

@@ -1,69 +0,0 @@
import React from 'react';
import { hot } from 'react-hot-loader';
import { observer } from 'mobx-react';
import { Team } from 'app/stores/TeamsStore/TeamsStore';
import { Label } from 'app/core/components/Forms/Forms';
interface Props {
team: Team;
}
@observer
export class TeamSettings extends React.Component<Props, any> {
constructor(props) {
super(props);
}
onChangeName = evt => {
this.props.team.setName(evt.target.value);
};
onChangeEmail = evt => {
this.props.team.setEmail(evt.target.value);
};
onUpdate = evt => {
evt.preventDefault();
this.props.team.update();
};
render() {
return (
<div>
<h3 className="page-sub-heading">Team Settings</h3>
<form name="teamDetailsForm" className="gf-form-group">
<div className="gf-form max-width-30">
<Label>Name</Label>
<input
type="text"
required
value={this.props.team.name}
className="gf-form-input max-width-22"
onChange={this.onChangeName}
/>
</div>
<div className="gf-form max-width-30">
<Label tooltip="This is optional and is primarily used to set the team profile avatar (via gravatar service)">
Email
</Label>
<input
type="email"
className="gf-form-input max-width-22"
value={this.props.team.email}
placeholder="team@email.com"
onChange={this.onChangeEmail}
/>
</div>
<div className="gf-form-button-row">
<button type="submit" className="btn btn-success" onClick={this.onUpdate}>
Update
</button>
</div>
</form>
</div>
);
}
}
export default hot(module)(TeamSettings);

View File

@@ -1,3 +1,4 @@
import { updateLocation } from './location';
import { updateNavIndex, UpdateNavIndexAction } from './navModel';
export { updateLocation };
export { updateLocation, updateNavIndex, UpdateNavIndexAction };

View File

@@ -1,13 +1,17 @@
export type Action = UpdateNavIndexAction;
import { NavModelItem } from '../../types';
// this action is not used yet
// kind of just a placeholder, will be need for dynamic pages
// like datasource edit, teams edit page
export interface UpdateNavIndexAction {
type: 'UPDATE_NAV_INDEX';
export enum ActionTypes {
UpdateNavIndex = 'UPDATE_NAV_INDEX',
}
export const updateNavIndex = (): UpdateNavIndexAction => ({
type: 'UPDATE_NAV_INDEX',
export type Action = UpdateNavIndexAction;
export interface UpdateNavIndexAction {
type: ActionTypes.UpdateNavIndex;
payload: NavModelItem;
}
export const updateNavIndex = (item: NavModelItem): UpdateNavIndexAction => ({
type: ActionTypes.UpdateNavIndex,
payload: item,
});

View File

@@ -13,7 +13,6 @@ interface Props {
* Wraps component into <Scrollbars> component from `react-custom-scrollbars`
*/
class CustomScrollbar extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
customClassName: 'custom-scrollbars',
autoHide: true,

View File

@@ -6,7 +6,6 @@ exports[`TeamPicker renders correctly 1`] = `
>
<div
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
style={undefined}
>
<div
className="Select-control"
@@ -15,9 +14,8 @@ exports[`TeamPicker renders correctly 1`] = `
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
style={undefined}
>
<span
<div
className="Select-multi-value-wrapper"
id="react-select-2--value"
>
@@ -36,14 +34,9 @@ exports[`TeamPicker renders correctly 1`] = `
>
<input
aria-activedescendant="react-select-2--value"
aria-describedby={undefined}
aria-expanded="false"
aria-haspopup="false"
aria-label={undefined}
aria-labelledby={undefined}
aria-owns=""
className={undefined}
id={undefined}
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
@@ -55,7 +48,6 @@ exports[`TeamPicker renders correctly 1`] = `
"width": "5px",
}
}
tabIndex={undefined}
value=""
/>
<div
@@ -74,7 +66,7 @@ exports[`TeamPicker renders correctly 1`] = `
</div>
</div>
</span>
</div>
<span
aria-hidden="true"
className="Select-loading-zone"

View File

@@ -6,7 +6,6 @@ exports[`UserPicker renders correctly 1`] = `
>
<div
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
style={undefined}
>
<div
className="Select-control"
@@ -15,9 +14,8 @@ exports[`UserPicker renders correctly 1`] = `
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
style={undefined}
>
<span
<div
className="Select-multi-value-wrapper"
id="react-select-2--value"
>
@@ -36,14 +34,9 @@ exports[`UserPicker renders correctly 1`] = `
>
<input
aria-activedescendant="react-select-2--value"
aria-describedby={undefined}
aria-expanded="false"
aria-haspopup="false"
aria-label={undefined}
aria-labelledby={undefined}
aria-owns=""
className={undefined}
id={undefined}
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
@@ -55,7 +48,6 @@ exports[`UserPicker renders correctly 1`] = `
"width": "5px",
}
}
tabIndex={undefined}
value=""
/>
<div
@@ -74,7 +66,7 @@ exports[`UserPicker renders correctly 1`] = `
</div>
</div>
</span>
</div>
<span
aria-hidden="true"
className="Select-loading-zone"

View File

@@ -6,12 +6,29 @@ Array [
className="sidemenu__logo"
key="logo"
onClick={[Function]}
/>,
>
<img
alt="graphana_logo"
src="public/img/grafana_icon.svg"
/>
</div>,
<div
className="sidemenu__logo_small_breakpoint"
key="hamburger"
onClick={[Function]}
/>,
>
<i
className="fa fa-bars"
/>
<span
className="sidemenu__close"
>
<i
className="fa fa-times"
/>
 Close
</span>
</div>,
<TopSection
key="topsection"
/>,

View File

@@ -6,7 +6,7 @@ exports[`Render should render component 1`] = `
>
<a
className="sidemenu-link"
href="login?redirect=blank"
href="login?redirect=%2F"
target="_self"
>
<span
@@ -18,7 +18,7 @@ exports[`Render should render component 1`] = `
</span>
</a>
<a
href="login?redirect=blank"
href="login?redirect=%2F"
target="_self"
>
<ul

View File

@@ -1,5 +1,5 @@
import { Action } from 'app/core/actions/navModel';
import { NavModelItem, NavIndex } from 'app/types';
import { Action, ActionTypes } from 'app/core/actions/navModel';
import { NavIndex, NavModelItem } from 'app/types';
import config from 'app/core/config';
export function buildInitialState(): NavIndex {
@@ -25,5 +25,19 @@ function buildNavIndex(navIndex: NavIndex, children: NavModelItem[], parentItem?
export const initialState: NavIndex = buildInitialState();
export const navIndexReducer = (state = initialState, action: Action): NavIndex => {
switch (action.type) {
case ActionTypes.UpdateNavIndex:
const newPages = {};
const payload = action.payload;
for (const node of payload.children) {
newPages[node.id] = {
...node,
parentItem: payload,
};
}
return { ...state, ...newPages };
}
return state;
};

View File

@@ -0,0 +1,3 @@
export const getRouteParamsId = state => state.routeParams.id;
export const getRouteParamsPage = state => state.routeParams.page;

View File

@@ -66,7 +66,6 @@ exports[`ServerStats Should render table with stats 1`] = `
<a
className="gf-tabs-link active"
href="Admin"
target={undefined}
>
<i
className="icon"

View File

@@ -1,15 +1,15 @@
import { getBackendSrv } from 'app/core/services/backend_srv';
import { AlertRuleApi, StoreState } from 'app/types';
import { AlertRuleDTO, StoreState } from 'app/types';
import { ThunkAction } from 'redux-thunk';
export enum ActionTypes {
LoadAlertRules = 'LOAD_ALERT_RULES',
SetSearchQuery = 'SET_SEARCH_QUERY',
SetSearchQuery = 'SET_ALERT_SEARCH_QUERY',
}
export interface LoadAlertRulesAction {
type: ActionTypes.LoadAlertRules;
payload: AlertRuleApi[];
payload: AlertRuleDTO[];
}
export interface SetSearchQueryAction {
@@ -17,7 +17,7 @@ export interface SetSearchQueryAction {
payload: string;
}
export const loadAlertRules = (rules: AlertRuleApi[]): LoadAlertRulesAction => ({
export const loadAlertRules = (rules: AlertRuleDTO[]): LoadAlertRulesAction => ({
type: ActionTypes.LoadAlertRules,
payload: rules,
});

View File

@@ -1,9 +1,9 @@
import { ActionTypes, Action } from './actions';
import { alertRulesReducer, initialState } from './reducers';
import { AlertRuleApi } from '../../../types';
import { AlertRuleDTO } from 'app/types';
describe('Alert rules', () => {
const payload: AlertRuleApi[] = [
const payload: AlertRuleDTO[] = [
{
id: 2,
dashboardId: 7,

View File

@@ -1,5 +1,5 @@
import moment from 'moment';
import { AlertRuleApi, AlertRule, AlertRulesState } from 'app/types';
import { AlertRuleDTO, AlertRule, AlertRulesState } from 'app/types';
import { Action, ActionTypes } from './actions';
import alertDef from './alertDef';
@@ -29,7 +29,7 @@ function convertToAlertRule(rule, state): AlertRule {
export const alertRulesReducer = (state = initialState, action: Action): AlertRulesState => {
switch (action.type) {
case ActionTypes.LoadAlertRules: {
const alertRules: AlertRuleApi[] = action.payload;
const alertRules: AlertRuleDTO[] = action.payload;
const alertRulesViewModel: AlertRule[] = alertRules.map(rule => {
return convertToAlertRule(rule, rule.state);

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Props, TeamGroupSync } from './TeamGroupSync';
import { TeamGroup } from '../../types';
import { getMockTeamGroups } from './__mocks__/teamMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
groups: [] as TeamGroup[],
loadTeamGroups: jest.fn(),
addTeamGroup: jest.fn(),
removeTeamGroup: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = shallow(<TeamGroupSync {...props} />);
const instance = wrapper.instance() as TeamGroupSync;
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render groups table', () => {
const { wrapper } = setup({
groups: getMockTeamGroups(3),
});
expect(wrapper).toMatchSnapshot();
});
});
describe('Functions', () => {
it('should call add group', () => {
const { instance } = setup();
instance.setState({ newGroupId: 'some/group' });
const mockEvent = { preventDefault: jest.fn() };
instance.onAddGroup(mockEvent);
expect(instance.props.addTeamGroup).toHaveBeenCalledWith('some/group');
});
it('should call remove group', () => {
const { instance } = setup();
const mockGroup: TeamGroup = { teamId: 1, groupId: 'some/group' };
instance.onRemoveGroup(mockGroup);
expect(instance.props.removeTeamGroup).toHaveBeenCalledWith('some/group');
});
});

View File

@@ -1,12 +1,16 @@
import React from 'react';
import { hot } from 'react-hot-loader';
import { observer } from 'mobx-react';
import { Team, TeamGroup } from 'app/stores/TeamsStore/TeamsStore';
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import SlideDown from 'app/core/components/Animations/SlideDown';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import { TeamGroup } from '../../types';
import { addTeamGroup, loadTeamGroups, removeTeamGroup } from './state/actions';
import { getTeamGroups } from './state/selectors';
interface Props {
team: Team;
export interface Props {
groups: TeamGroup[];
loadTeamGroups: typeof loadTeamGroups;
addTeamGroup: typeof addTeamGroup;
removeTeamGroup: typeof removeTeamGroup;
}
interface State {
@@ -16,15 +20,40 @@ interface State {
const headerTooltip = `Sync LDAP or OAuth groups with your Grafana teams.`;
@observer
export class TeamGroupSync extends React.Component<Props, State> {
export class TeamGroupSync extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = { isAdding: false, newGroupId: '' };
}
componentDidMount() {
this.props.team.loadGroups();
this.fetchTeamGroups();
}
async fetchTeamGroups() {
await this.props.loadTeamGroups();
}
onToggleAdding = () => {
this.setState({ isAdding: !this.state.isAdding });
};
onNewGroupIdChanged = event => {
this.setState({ newGroupId: event.target.value });
};
onAddGroup = event => {
event.preventDefault();
this.props.addTeamGroup(this.state.newGroupId);
this.setState({ isAdding: false, newGroupId: '' });
};
onRemoveGroup = (group: TeamGroup) => {
this.props.removeTeamGroup(group.groupId);
};
isNewGroupValid() {
return this.state.newGroupId.length > 1;
}
renderGroup(group: TeamGroup) {
@@ -40,30 +69,9 @@ export class TeamGroupSync extends React.Component<Props, State> {
);
}
onToggleAdding = () => {
this.setState({ isAdding: !this.state.isAdding });
};
onNewGroupIdChanged = evt => {
this.setState({ newGroupId: evt.target.value });
};
onAddGroup = () => {
this.props.team.addGroup(this.state.newGroupId);
this.setState({ isAdding: false, newGroupId: '' });
};
onRemoveGroup = (group: TeamGroup) => {
this.props.team.removeGroup(group.groupId);
};
isNewGroupValid() {
return this.state.newGroupId.length > 1;
}
render() {
const { isAdding, newGroupId } = this.state;
const groups = this.props.team.groups.values();
const groups = this.props.groups;
return (
<div>
@@ -86,7 +94,7 @@ export class TeamGroupSync extends React.Component<Props, State> {
<i className="fa fa-close" />
</button>
<h5>Add External Group</h5>
<div className="gf-form-inline">
<form className="gf-form-inline" onSubmit={this.onAddGroup}>
<div className="gf-form">
<input
type="text"
@@ -98,16 +106,11 @@ export class TeamGroupSync extends React.Component<Props, State> {
</div>
<div className="gf-form">
<button
className="btn btn-success gf-form-btn"
onClick={this.onAddGroup}
type="submit"
disabled={!this.isNewGroupValid()}
>
<button className="btn btn-success gf-form-btn" type="submit" disabled={!this.isNewGroupValid()}>
Add group
</button>
</div>
</div>
</form>
</div>
</SlideDown>
@@ -146,4 +149,16 @@ export class TeamGroupSync extends React.Component<Props, State> {
}
}
export default hot(module)(TeamGroupSync);
function mapStateToProps(state) {
return {
groups: getTeamGroups(state.team),
};
}
const mapDispatchToProps = {
loadTeamGroups,
addTeamGroup,
removeTeamGroup,
};
export default connect(mapStateToProps, mapDispatchToProps)(TeamGroupSync);

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Props, TeamList } from './TeamList';
import { NavModel, Team } from '../../types';
import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
navModel: {} as NavModel,
teams: [] as Team[],
loadTeams: jest.fn(),
deleteTeam: jest.fn(),
setSearchQuery: jest.fn(),
searchQuery: '',
teamsCount: 0,
};
Object.assign(props, propOverrides);
const wrapper = shallow(<TeamList {...props} />);
const instance = wrapper.instance() as TeamList;
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render teams table', () => {
const { wrapper } = setup({
teams: getMultipleMockTeams(5),
teamsCount: 5,
});
expect(wrapper).toMatchSnapshot();
});
});
describe('Life cycle', () => {
it('should call loadTeams', () => {
const { instance } = setup();
instance.componentDidMount();
expect(instance.props.loadTeams).toHaveBeenCalled();
});
});
describe('Functions', () => {
describe('Delete team', () => {
it('should call delete team', () => {
const { instance } = setup();
instance.deleteTeam(getMockTeam());
expect(instance.props.deleteTeam).toHaveBeenCalledWith(1);
});
});
describe('on search query change', () => {
it('should call setSearchQuery', () => {
const { instance } = setup();
const mockEvent = { target: { value: 'test' } };
instance.onSearchQueryChange(mockEvent);
expect(instance.props.setSearchQuery).toHaveBeenCalledWith('test');
});
});
});

View File

@@ -1,42 +1,42 @@
import React from 'react';
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import { inject, observer } from 'mobx-react';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import { NavStore } from 'app/stores/NavStore/NavStore';
import { TeamsStore, Team } from 'app/stores/TeamsStore/TeamsStore';
import { BackendSrv } from 'app/core/services/backend_srv';
import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { NavModel, Team } from '../../types';
import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
import { getNavModel } from 'app/core/selectors/navModel';
interface Props {
nav: typeof NavStore.Type;
teams: typeof TeamsStore.Type;
backendSrv: BackendSrv;
export interface Props {
navModel: NavModel;
teams: Team[];
searchQuery: string;
teamsCount: number;
loadTeams: typeof loadTeams;
deleteTeam: typeof deleteTeam;
setSearchQuery: typeof setSearchQuery;
}
@inject('nav', 'teams')
@observer
export class TeamList extends React.Component<Props, any> {
constructor(props) {
super(props);
this.props.nav.load('cfg', 'teams');
export class TeamList extends PureComponent<Props, any> {
componentDidMount() {
this.fetchTeams();
}
fetchTeams() {
this.props.teams.loadTeams();
async fetchTeams() {
await this.props.loadTeams();
}
deleteTeam(team: Team) {
this.props.backendSrv.delete('/api/teams/' + team.id).then(this.fetchTeams.bind(this));
}
onSearchQueryChange = evt => {
this.props.teams.setSearchQuery(evt.target.value);
deleteTeam = (team: Team) => {
this.props.deleteTeam(team.id);
};
renderTeamMember(team: Team): JSX.Element {
onSearchQueryChange = event => {
this.props.setSearchQuery(event.target.value);
};
renderTeam(team: Team) {
const teamUrl = `org/teams/edit/${team.id}`;
return (
@@ -62,7 +62,28 @@ export class TeamList extends React.Component<Props, any> {
);
}
renderTeamList(teams) {
renderEmptyList() {
return (
<div className="page-container page-body">
<EmptyListCTA
model={{
title: "You haven't created any teams yet.",
buttonIcon: 'fa fa-plus',
buttonLink: 'org/teams/new',
buttonTitle: ' New team',
proTip: 'Assign folder and dashboard permissions to teams instead of users to ease administration.',
proTipLink: '',
proTipLinkTitle: '',
proTipTarget: '_blank',
}}
/>
</div>
);
}
renderTeamList() {
const { teams, searchQuery } = this.props;
return (
<div className="page-container page-body">
<div className="page-action-bar">
@@ -72,7 +93,7 @@ export class TeamList extends React.Component<Props, any> {
type="text"
className="gf-form-input"
placeholder="Search teams"
value={teams.search}
value={searchQuery}
onChange={this.onSearchQueryChange}
/>
<i className="gf-form-input-icon fa fa-search" />
@@ -97,49 +118,38 @@ export class TeamList extends React.Component<Props, any> {
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>{teams.filteredTeams.map(team => this.renderTeamMember(team))}</tbody>
<tbody>{teams.map(team => this.renderTeam(team))}</tbody>
</table>
</div>
</div>
);
}
renderEmptyList() {
return (
<div className="page-container page-body">
<EmptyListCTA
model={{
title: "You haven't created any teams yet.",
buttonIcon: 'fa fa-plus',
buttonLink: 'org/teams/new',
buttonTitle: ' New team',
proTip: 'Assign folder and dashboard permissions to teams instead of users to ease administration.',
proTipLink: '',
proTipLinkTitle: '',
proTipTarget: '_blank',
}}
/>
</div>
);
}
render() {
const { nav, teams } = this.props;
let view;
if (teams.filteredTeams.length > 0) {
view = this.renderTeamList(teams);
} else {
view = this.renderEmptyList();
}
const { navModel, teamsCount } = this.props;
return (
<div>
<PageHeader model={nav as any} />
{view}
<PageHeader model={navModel} />
{teamsCount > 0 ? this.renderTeamList() : this.renderEmptyList()}
</div>
);
}
}
export default hot(module)(TeamList);
function mapStateToProps(state) {
return {
navModel: getNavModel(state.navIndex, 'teams'),
teams: getTeams(state.teams),
searchQuery: getSearchQuery(state.teams),
teamsCount: getTeamsCount(state.teams),
};
}
const mapDispatchToProps = {
loadTeams,
deleteTeam,
setSearchQuery,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TeamList));

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TeamMembers, Props, State } from './TeamMembers';
import { TeamMember } from '../../types';
import { getMockTeamMember, getMockTeamMembers } from './__mocks__/teamMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
members: [] as TeamMember[],
searchMemberQuery: '',
setSearchMemberQuery: jest.fn(),
loadTeamMembers: jest.fn(),
addTeamMember: jest.fn(),
removeTeamMember: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = shallow(<TeamMembers {...props} />);
const instance = wrapper.instance() as TeamMembers;
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render team members', () => {
const { wrapper } = setup({
members: getMockTeamMembers(5),
});
expect(wrapper).toMatchSnapshot();
});
});
describe('Functions', () => {
describe('on search member query change', () => {
it('it should call setSearchMemberQuery', () => {
const { instance } = setup();
const mockEvent = { target: { value: 'member' } };
instance.onSearchQueryChange(mockEvent);
expect(instance.props.setSearchMemberQuery).toHaveBeenCalledWith('member');
});
});
describe('on remove member', () => {
const { instance } = setup();
const mockTeamMember = getMockTeamMember();
instance.onRemoveMember(mockTeamMember);
expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1);
});
describe('on add user to team', () => {
const { wrapper, instance } = setup();
const state = wrapper.state() as State;
state.newTeamMember = {
id: 1,
label: '',
avatarUrl: '',
login: '',
};
instance.onAddUserToTeam();
expect(instance.props.addTeamMember).toHaveBeenCalledWith(1);
});
});

View File

@@ -1,56 +1,42 @@
import React from 'react';
import { hot } from 'react-hot-loader';
import { observer } from 'mobx-react';
import { Team, TeamMember } from 'app/stores/TeamsStore/TeamsStore';
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import SlideDown from 'app/core/components/Animations/SlideDown';
import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
import { TeamMember } from '../../types';
import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions';
import { getSearchMemberQuery, getTeamMembers } from './state/selectors';
interface Props {
team: Team;
export interface Props {
members: TeamMember[];
searchMemberQuery: string;
loadTeamMembers: typeof loadTeamMembers;
addTeamMember: typeof addTeamMember;
removeTeamMember: typeof removeTeamMember;
setSearchMemberQuery: typeof setSearchMemberQuery;
}
interface State {
export interface State {
isAdding: boolean;
newTeamMember?: User;
}
@observer
export class TeamMembers extends React.Component<Props, State> {
export class TeamMembers extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = { isAdding: false, newTeamMember: null };
}
componentDidMount() {
this.props.team.loadMembers();
this.props.loadTeamMembers();
}
onSearchQueryChange = evt => {
this.props.team.setSearchQuery(evt.target.value);
onSearchQueryChange = event => {
this.props.setSearchMemberQuery(event.target.value);
};
removeMember(member: TeamMember) {
this.props.team.removeMember(member);
}
removeMemberConfirmed(member: TeamMember) {
this.props.team.removeMember(member);
}
renderMember(member: TeamMember) {
return (
<tr key={member.userId}>
<td className="width-4 text-center">
<img className="filter-table__avatar" src={member.avatarUrl} />
</td>
<td>{member.login}</td>
<td>{member.email}</td>
<td className="text-right">
<DeleteButton onConfirmDelete={() => this.removeMember(member)} />
</td>
</tr>
);
onRemoveMember(member: TeamMember) {
this.props.removeTeamMember(member.userId);
}
onToggleAdding = () => {
@@ -62,16 +48,29 @@ export class TeamMembers extends React.Component<Props, State> {
};
onAddUserToTeam = async () => {
await this.props.team.addMember(this.state.newTeamMember.id);
await this.props.team.loadMembers();
this.props.addTeamMember(this.state.newTeamMember.id);
this.setState({ newTeamMember: null });
};
renderMember(member: TeamMember) {
return (
<tr key={member.userId}>
<td className="width-4 text-center">
<img className="filter-table__avatar" src={member.avatarUrl} />
</td>
<td>{member.login}</td>
<td>{member.email}</td>
<td className="text-right">
<DeleteButton onConfirmDelete={() => this.onRemoveMember(member)} />
</td>
</tr>
);
}
render() {
const { newTeamMember, isAdding } = this.state;
const members = this.props.team.filteredMembers;
const { searchMemberQuery, members } = this.props;
const newTeamMemberValue = newTeamMember && newTeamMember.id.toString();
const { team } = this.props;
return (
<div>
@@ -82,7 +81,7 @@ export class TeamMembers extends React.Component<Props, State> {
type="text"
className="gf-form-input"
placeholder="Search members"
value={team.search}
value={searchMemberQuery}
onChange={this.onSearchQueryChange}
/>
<i className="gf-form-input-icon fa fa-search" />
@@ -124,7 +123,7 @@ export class TeamMembers extends React.Component<Props, State> {
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>{members.map(member => this.renderMember(member))}</tbody>
<tbody>{members && members.map(member => this.renderMember(member))}</tbody>
</table>
</div>
</div>
@@ -132,4 +131,18 @@ export class TeamMembers extends React.Component<Props, State> {
}
}
export default hot(module)(TeamMembers);
function mapStateToProps(state) {
return {
members: getTeamMembers(state.team),
searchMemberQuery: getSearchMemberQuery(state.team),
};
}
const mapDispatchToProps = {
loadTeamMembers,
addTeamMember,
removeTeamMember,
setSearchMemberQuery,
};
export default connect(mapStateToProps, mapDispatchToProps)(TeamMembers);

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TeamPages, Props } from './TeamPages';
import { NavModel, Team } from '../../types';
import { getMockTeam } from './__mocks__/teamMocks';
jest.mock('app/core/config', () => ({
buildInfo: { isEnterprise: true },
}));
const setup = (propOverrides?: object) => {
const props: Props = {
navModel: {} as NavModel,
teamId: 1,
loadTeam: jest.fn(),
pageName: 'members',
team: {} as Team,
};
Object.assign(props, propOverrides);
const wrapper = shallow(<TeamPages {...props} />);
const instance = wrapper.instance();
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render member page if team not empty', () => {
const { wrapper } = setup({
team: getMockTeam(),
});
expect(wrapper).toMatchSnapshot();
});
it('should render settings page', () => {
const { wrapper } = setup({
team: getMockTeam(),
pageName: 'settings',
});
expect(wrapper).toMatchSnapshot();
});
it('should render group sync page', () => {
const { wrapper } = setup({
team: getMockTeam(),
pageName: 'groupsync',
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,105 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import _ from 'lodash';
import { hot } from 'react-hot-loader';
import config from 'app/core/config';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import TeamMembers from './TeamMembers';
import TeamSettings from './TeamSettings';
import TeamGroupSync from './TeamGroupSync';
import { NavModel, Team } from '../../types';
import { loadTeam } from './state/actions';
import { getTeam } from './state/selectors';
import { getNavModel } from '../../core/selectors/navModel';
import { getRouteParamsId, getRouteParamsPage } from '../../core/selectors/location';
export interface Props {
team: Team;
loadTeam: typeof loadTeam;
teamId: number;
pageName: string;
navModel: NavModel;
}
interface State {
isSyncEnabled: boolean;
}
enum PageTypes {
Members = 'members',
Settings = 'settings',
GroupSync = 'groupsync',
}
export class TeamPages extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
isSyncEnabled: config.buildInfo.isEnterprise,
};
}
componentDidMount() {
this.fetchTeam();
}
async fetchTeam() {
const { loadTeam, teamId } = this.props;
await loadTeam(teamId);
}
getCurrentPage() {
const pages = ['members', 'settings', 'groupsync'];
const currentPage = this.props.pageName;
return _.includes(pages, currentPage) ? currentPage : pages[0];
}
renderPage() {
const { isSyncEnabled } = this.state;
const currentPage = this.getCurrentPage();
switch (currentPage) {
case PageTypes.Members:
return <TeamMembers />;
case PageTypes.Settings:
return <TeamSettings />;
case PageTypes.GroupSync:
return isSyncEnabled && <TeamGroupSync />;
}
return null;
}
render() {
const { team, navModel } = this.props;
return (
<div>
<PageHeader model={navModel} />
{team && Object.keys(team).length !== 0 && <div className="page-container page-body">{this.renderPage()}</div>}
</div>
);
}
}
function mapStateToProps(state) {
const teamId = getRouteParamsId(state.location);
const pageName = getRouteParamsPage(state.location) || 'members';
return {
navModel: getNavModel(state.navIndex, `team-${pageName}-${teamId}`),
teamId: teamId,
pageName: pageName,
team: getTeam(state.team, teamId),
};
}
const mapDispatchToProps = {
loadTeam,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TeamPages));

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Props, TeamSettings } from './TeamSettings';
import { getMockTeam } from './__mocks__/teamMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
team: getMockTeam(),
updateTeam: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = shallow(<TeamSettings {...props} />);
const instance = wrapper.instance() as TeamSettings;
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
});
describe('Functions', () => {
it('should update team', () => {
const { instance } = setup();
const mockEvent = { preventDefault: jest.fn() };
instance.setState({
name: 'test11',
});
instance.onUpdate(mockEvent);
expect(instance.props.updateTeam).toHaveBeenCalledWith('test11', 'test@test.com');
});
});

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { connect } from 'react-redux';
import { Label } from 'app/core/components/Forms/Forms';
import { Team } from '../../types';
import { updateTeam } from './state/actions';
import { getRouteParamsId } from '../../core/selectors/location';
import { getTeam } from './state/selectors';
export interface Props {
team: Team;
updateTeam: typeof updateTeam;
}
interface State {
name: string;
email: string;
}
export class TeamSettings extends React.Component<Props, State> {
constructor(props) {
super(props);
this.state = {
name: props.team.name,
email: props.team.email,
};
}
onChangeName = event => {
this.setState({ name: event.target.value });
};
onChangeEmail = event => {
this.setState({ email: event.target.value });
};
onUpdate = event => {
const { name, email } = this.state;
event.preventDefault();
this.props.updateTeam(name, email);
};
render() {
const { name, email } = this.state;
return (
<div>
<h3 className="page-sub-heading">Team Settings</h3>
<form name="teamDetailsForm" className="gf-form-group" onSubmit={this.onUpdate}>
<div className="gf-form max-width-30">
<Label>Name</Label>
<input
type="text"
required
value={name}
className="gf-form-input max-width-22"
onChange={this.onChangeName}
/>
</div>
<div className="gf-form max-width-30">
<Label tooltip="This is optional and is primarily used to set the team profile avatar (via gravatar service)">
Email
</Label>
<input
type="email"
className="gf-form-input max-width-22"
value={email}
placeholder="team@email.com"
onChange={this.onChangeEmail}
/>
</div>
<div className="gf-form-button-row">
<button type="submit" className="btn btn-success">
Update
</button>
</div>
</form>
</div>
);
}
}
function mapStateToProps(state) {
const teamId = getRouteParamsId(state.location);
return {
team: getTeam(state.team, teamId),
};
}
const mapDispatchToProps = {
updateTeam,
};
export default connect(mapStateToProps, mapDispatchToProps)(TeamSettings);

View File

@@ -0,0 +1,59 @@
export const getMockNavModel = (pageName: string) => {
return {
node: {
active: false,
icon: 'gicon gicon-team',
id: `team-${pageName}-2`,
text: `${pageName}`,
url: 'org/teams/edit/2/members',
parentItem: {
img: '/avatar/b5695b61c91d13e7fa2fe71cfb95de9b',
id: 'team-2',
subTitle: 'Manage members & settings',
url: '',
text: 'test1',
breadcrumbs: [{ title: 'Teams', url: 'org/teams' }],
children: [
{
active: false,
icon: 'gicon gicon-team',
id: 'team-members-2',
text: 'Members',
url: 'org/teams/edit/2/members',
},
{
active: false,
icon: 'fa fa-fw fa-sliders',
id: 'team-settings-2',
text: 'Settings',
url: 'org/teams/edit/2/settings',
},
],
},
},
main: {
img: '/avatar/b5695b61c91d13e7fa2fe71cfb95de9b',
id: 'team-2',
subTitle: 'Manage members & settings',
url: '',
text: 'test1',
breadcrumbs: [{ title: 'Teams', url: 'org/teams' }],
children: [
{
active: true,
icon: 'gicon gicon-team',
id: 'team-members-2',
text: 'Members',
url: 'org/teams/edit/2/members',
},
{
active: false,
icon: 'fa fa-fw fa-sliders',
id: 'team-settings-2',
text: 'Settings',
url: 'org/teams/edit/2/settings',
},
],
},
};
};

View File

@@ -0,0 +1,65 @@
import { Team, TeamGroup, TeamMember } from '../../../types';
export const getMultipleMockTeams = (numberOfTeams: number): Team[] => {
const teams: Team[] = [];
for (let i = 1; i <= numberOfTeams; i++) {
teams.push({
id: i,
name: `test-${i}`,
avatarUrl: 'some/url/',
email: `test-${i}@test.com`,
memberCount: i,
});
}
return teams;
};
export const getMockTeam = (): Team => {
return {
id: 1,
name: 'test',
avatarUrl: 'some/url/',
email: 'test@test.com',
memberCount: 1,
};
};
export const getMockTeamMembers = (amount: number): TeamMember[] => {
const teamMembers: TeamMember[] = [];
for (let i = 1; i <= amount; i++) {
teamMembers.push({
userId: i,
teamId: 1,
avatarUrl: 'some/url/',
email: 'test@test.com',
login: `testUser-${i}`,
});
}
return teamMembers;
};
export const getMockTeamMember = (): TeamMember => {
return {
userId: 1,
teamId: 1,
avatarUrl: 'some/url/',
email: 'test@test.com',
login: 'testUser',
};
};
export const getMockTeamGroups = (amount: number): TeamGroup[] => {
const groups: TeamGroup[] = [];
for (let i = 1; i <= amount; i++) {
groups.push({
groupId: `group-${i}`,
teamId: 1,
});
}
return groups;
};

View File

@@ -0,0 +1,281 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div>
<div
className="page-action-bar"
>
<h3
className="page-sub-heading"
>
External group sync
</h3>
<class_1
className="page-sub-heading-icon"
content="Sync LDAP or OAuth groups with your Grafana teams."
placement="auto"
>
<i
className="gicon gicon-question gicon--has-hover"
/>
</class_1>
<div
className="page-action-bar__spacer"
/>
</div>
<Component
in={false}
>
<div
className="cta-form"
>
<button
className="cta-form__close btn btn-transparent"
onClick={[Function]}
>
<i
className="fa fa-close"
/>
</button>
<h5>
Add External Group
</h5>
<form
className="gf-form-inline"
onSubmit={[Function]}
>
<div
className="gf-form"
>
<input
className="gf-form-input width-30"
onChange={[Function]}
placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
type="text"
value=""
/>
</div>
<div
className="gf-form"
>
<button
className="btn btn-success gf-form-btn"
disabled={true}
type="submit"
>
Add group
</button>
</div>
</form>
</div>
</Component>
<div
className="empty-list-cta"
>
<div
className="empty-list-cta__title"
>
There are no external groups to sync with
</div>
<button
className="empty-list-cta__button btn btn-xlarge btn-success"
onClick={[Function]}
>
<i
className="gicon gicon-add-team"
/>
Add Group
</button>
<div
className="empty-list-cta__pro-tip"
>
<i
className="fa fa-rocket"
/>
Sync LDAP or OAuth groups with your Grafana teams.
<a
className="text-link empty-list-cta__pro-tip-link"
href="asd"
target="_blank"
>
Learn more
</a>
</div>
</div>
</div>
`;
exports[`Render should render groups table 1`] = `
<div>
<div
className="page-action-bar"
>
<h3
className="page-sub-heading"
>
External group sync
</h3>
<class_1
className="page-sub-heading-icon"
content="Sync LDAP or OAuth groups with your Grafana teams."
placement="auto"
>
<i
className="gicon gicon-question gicon--has-hover"
/>
</class_1>
<div
className="page-action-bar__spacer"
/>
<button
className="btn btn-success pull-right"
onClick={[Function]}
>
<i
className="fa fa-plus"
/>
Add group
</button>
</div>
<Component
in={false}
>
<div
className="cta-form"
>
<button
className="cta-form__close btn btn-transparent"
onClick={[Function]}
>
<i
className="fa fa-close"
/>
</button>
<h5>
Add External Group
</h5>
<form
className="gf-form-inline"
onSubmit={[Function]}
>
<div
className="gf-form"
>
<input
className="gf-form-input width-30"
onChange={[Function]}
placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
type="text"
value=""
/>
</div>
<div
className="gf-form"
>
<button
className="btn btn-success gf-form-btn"
disabled={true}
type="submit"
>
Add group
</button>
</div>
</form>
</div>
</Component>
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th>
External Group ID
</th>
<th
style={
Object {
"width": "1%",
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="group-1"
>
<td>
group-1
</td>
<td
style={
Object {
"width": "1%",
}
}
>
<a
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</a>
</td>
</tr>
<tr
key="group-2"
>
<td>
group-2
</td>
<td
style={
Object {
"width": "1%",
}
}
>
<a
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</a>
</td>
</tr>
<tr
key="group-3"
>
<td>
group-3
</td>
<td
style={
Object {
"width": "1%",
}
}
>
<a
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`;

View File

@@ -0,0 +1,354 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<EmptyListCTA
model={
Object {
"buttonIcon": "fa fa-plus",
"buttonLink": "org/teams/new",
"buttonTitle": " New team",
"proTip": "Assign folder and dashboard permissions to teams instead of users to ease administration.",
"proTipLink": "",
"proTipLinkTitle": "",
"proTipTarget": "_blank",
"title": "You haven't created any teams yet.",
}
}
/>
</div>
</div>
`;
exports[`Render should render teams table 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon gf-form--grow"
>
<input
className="gf-form-input"
onChange={[Function]}
placeholder="Search teams"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
</div>
<div
className="page-action-bar__spacer"
/>
<a
className="btn btn-success"
href="org/teams/new"
>
<i
className="fa fa-plus"
/>
New team
</a>
</div>
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th>
Members
</th>
<th
style={
Object {
"width": "1%",
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/1"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
1
</a>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
<tr
key="2"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/2"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/2"
>
test-2
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/2"
>
test-2@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/2"
>
2
</a>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
<tr
key="3"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/3"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/3"
>
test-3
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/3"
>
test-3@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/3"
>
3
</a>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
<tr
key="4"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/4"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/4"
>
test-4
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/4"
>
test-4@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/4"
>
4
</a>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
<tr
key="5"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/5"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/5"
>
test-5
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/5"
>
test-5@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/5"
>
5
</a>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,317 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon gf-form--grow"
>
<input
className="gf-form-input"
onChange={[Function]}
placeholder="Search members"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
</div>
<div
className="page-action-bar__spacer"
/>
<button
className="btn btn-success pull-right"
disabled={false}
onClick={[Function]}
>
<i
className="fa fa-plus"
/>
Add a member
</button>
</div>
<Component
in={false}
>
<div
className="cta-form"
>
<button
className="cta-form__close btn btn-transparent"
onClick={[Function]}
>
<i
className="fa fa-close"
/>
</button>
<h5>
Add Team Member
</h5>
<div
className="gf-form-inline"
>
<UserPicker
className="width-30"
onSelected={[Function]}
value={null}
/>
</div>
</div>
</Component>
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th
style={
Object {
"width": "1%",
}
}
/>
</tr>
</thead>
<tbody />
</table>
</div>
</div>
`;
exports[`Render should render team members 1`] = `
<div>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon gf-form--grow"
>
<input
className="gf-form-input"
onChange={[Function]}
placeholder="Search members"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
</div>
<div
className="page-action-bar__spacer"
/>
<button
className="btn btn-success pull-right"
disabled={false}
onClick={[Function]}
>
<i
className="fa fa-plus"
/>
Add a member
</button>
</div>
<Component
in={false}
>
<div
className="cta-form"
>
<button
className="cta-form__close btn btn-transparent"
onClick={[Function]}
>
<i
className="fa fa-close"
/>
</button>
<h5>
Add Team Member
</h5>
<div
className="gf-form-inline"
>
<UserPicker
className="width-30"
onSelected={[Function]}
value={null}
/>
</div>
</div>
</Component>
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th
style={
Object {
"width": "1%",
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-1
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
<tr
key="2"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-2
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
<tr
key="3"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-3
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
<tr
key="4"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-4
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
<tr
key="5"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser-5
</td>
<td>
test@test.com
</td>
<td
className="text-right"
>
<DeleteButton
onConfirmDelete={[Function]}
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`;

View File

@@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div>
<PageHeader
model={Object {}}
/>
</div>
`;
exports[`Render should render group sync page 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<Connect(TeamGroupSync) />
</div>
</div>
`;
exports[`Render should render member page if team not empty 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<Connect(TeamMembers) />
</div>
</div>
`;
exports[`Render should render settings page 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<Connect(TeamSettings) />
</div>
</div>
`;

View File

@@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div>
<h3
className="page-sub-heading"
>
Team Settings
</h3>
<form
className="gf-form-group"
name="teamDetailsForm"
onSubmit={[Function]}
>
<div
className="gf-form max-width-30"
>
<Component>
Name
</Component>
<input
className="gf-form-input max-width-22"
onChange={[Function]}
required={true}
type="text"
value="test"
/>
</div>
<div
className="gf-form max-width-30"
>
<Component
tooltip="This is optional and is primarily used to set the team profile avatar (via gravatar service)"
>
Email
</Component>
<input
className="gf-form-input max-width-22"
onChange={[Function]}
placeholder="team@email.com"
type="email"
value="test@test.com"
/>
</div>
<div
className="gf-form-button-row"
>
<button
className="btn btn-success"
type="submit"
>
Update
</button>
</div>
</form>
</div>
`;

View File

@@ -0,0 +1,237 @@
import { ThunkAction } from 'redux-thunk';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { NavModelItem, StoreState, Team, TeamGroup, TeamMember } from 'app/types';
import { updateNavIndex, UpdateNavIndexAction } from 'app/core/actions';
import config from 'app/core/config';
export enum ActionTypes {
LoadTeams = 'LOAD_TEAMS',
LoadTeam = 'LOAD_TEAM',
SetSearchQuery = 'SET_TEAM_SEARCH_QUERY',
SetSearchMemberQuery = 'SET_TEAM_MEMBER_SEARCH_QUERY',
LoadTeamMembers = 'TEAM_MEMBERS_LOADED',
LoadTeamGroups = 'TEAM_GROUPS_LOADED',
}
export interface LoadTeamsAction {
type: ActionTypes.LoadTeams;
payload: Team[];
}
export interface LoadTeamAction {
type: ActionTypes.LoadTeam;
payload: Team;
}
export interface LoadTeamMembersAction {
type: ActionTypes.LoadTeamMembers;
payload: TeamMember[];
}
export interface LoadTeamGroupsAction {
type: ActionTypes.LoadTeamGroups;
payload: TeamGroup[];
}
export interface SetSearchQueryAction {
type: ActionTypes.SetSearchQuery;
payload: string;
}
export interface SetSearchMemberQueryAction {
type: ActionTypes.SetSearchMemberQuery;
payload: string;
}
export type Action =
| LoadTeamsAction
| SetSearchQueryAction
| LoadTeamAction
| LoadTeamMembersAction
| SetSearchMemberQueryAction
| LoadTeamGroupsAction;
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action | UpdateNavIndexAction>;
const teamsLoaded = (teams: Team[]): LoadTeamsAction => ({
type: ActionTypes.LoadTeams,
payload: teams,
});
const teamLoaded = (team: Team): LoadTeamAction => ({
type: ActionTypes.LoadTeam,
payload: team,
});
const teamMembersLoaded = (teamMembers: TeamMember[]): LoadTeamMembersAction => ({
type: ActionTypes.LoadTeamMembers,
payload: teamMembers,
});
const teamGroupsLoaded = (teamGroups: TeamGroup[]): LoadTeamGroupsAction => ({
type: ActionTypes.LoadTeamGroups,
payload: teamGroups,
});
export const setSearchMemberQuery = (searchQuery: string): SetSearchMemberQueryAction => ({
type: ActionTypes.SetSearchMemberQuery,
payload: searchQuery,
});
export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({
type: ActionTypes.SetSearchQuery,
payload: searchQuery,
});
export function loadTeams(): ThunkResult<void> {
return async dispatch => {
const response = await getBackendSrv().get('/api/teams/search', { perpage: 1000, page: 1 });
dispatch(teamsLoaded(response.teams));
};
}
function buildNavModel(team: Team): NavModelItem {
const navModel = {
img: team.avatarUrl,
id: 'team-' + team.id,
subTitle: 'Manage members & settings',
url: '',
text: team.name,
breadcrumbs: [{ title: 'Teams', url: 'org/teams' }],
children: [
{
active: false,
icon: 'gicon gicon-team',
id: `team-members-${team.id}`,
text: 'Members',
url: `org/teams/edit/${team.id}/members`,
},
{
active: false,
icon: 'fa fa-fw fa-sliders',
id: `team-settings-${team.id}`,
text: 'Settings',
url: `org/teams/edit/${team.id}/settings`,
},
],
};
if (config.buildInfo.isEnterprise) {
navModel.children.push({
active: false,
icon: 'fa fa-fw fa-refresh',
id: `team-groupsync-${team.id}`,
text: 'External group sync',
url: `org/teams/edit/${team.id}/groupsync`,
});
}
return navModel;
}
export function loadTeam(id: number): ThunkResult<void> {
return async dispatch => {
await getBackendSrv()
.get(`/api/teams/${id}`)
.then(response => {
dispatch(teamLoaded(response));
dispatch(updateNavIndex(buildNavModel(response)));
});
};
}
export function loadTeamMembers(): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
await getBackendSrv()
.get(`/api/teams/${team.id}/members`)
.then(response => {
dispatch(teamMembersLoaded(response));
});
};
}
export function addTeamMember(id: number): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
await getBackendSrv()
.post(`/api/teams/${team.id}/members`, { userId: id })
.then(() => {
dispatch(loadTeamMembers());
});
};
}
export function removeTeamMember(id: number): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
await getBackendSrv()
.delete(`/api/teams/${team.id}/members/${id}`)
.then(() => {
dispatch(loadTeamMembers());
});
};
}
export function updateTeam(name: string, email: string): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
await getBackendSrv()
.put(`/api/teams/${team.id}`, {
name,
email,
})
.then(() => {
dispatch(loadTeam(team.id));
});
};
}
export function loadTeamGroups(): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
await getBackendSrv()
.get(`/api/teams/${team.id}/groups`)
.then(response => {
dispatch(teamGroupsLoaded(response));
});
};
}
export function addTeamGroup(groupId: string): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
await getBackendSrv()
.post(`/api/teams/${team.id}/groups`, { groupId: groupId })
.then(() => {
dispatch(loadTeamGroups());
});
};
}
export function removeTeamGroup(groupId: string): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
await getBackendSrv()
.delete(`/api/teams/${team.id}/groups/${groupId}`)
.then(() => {
dispatch(loadTeamGroups());
});
};
}
export function deleteTeam(id: number): ThunkResult<void> {
return async dispatch => {
await getBackendSrv()
.delete(`/api/teams/${id}`)
.then(() => {
dispatch(loadTeams());
});
};
}

View File

@@ -0,0 +1,72 @@
import { Action, ActionTypes } from './actions';
import { initialTeamsState, initialTeamState, teamReducer, teamsReducer } from './reducers';
import { getMockTeam, getMockTeamMember } from '../__mocks__/teamMocks';
describe('teams reducer', () => {
it('should set teams', () => {
const payload = [getMockTeam()];
const action: Action = {
type: ActionTypes.LoadTeams,
payload,
};
const result = teamsReducer(initialTeamsState, action);
expect(result.teams).toEqual(payload);
});
it('should set search query', () => {
const payload = 'test';
const action: Action = {
type: ActionTypes.SetSearchQuery,
payload,
};
const result = teamsReducer(initialTeamsState, action);
expect(result.searchQuery).toEqual('test');
});
});
describe('team reducer', () => {
it('should set team', () => {
const payload = getMockTeam();
const action: Action = {
type: ActionTypes.LoadTeam,
payload,
};
const result = teamReducer(initialTeamState, action);
expect(result.team).toEqual(payload);
});
it('should set team members', () => {
const mockTeamMember = getMockTeamMember();
const action: Action = {
type: ActionTypes.LoadTeamMembers,
payload: [mockTeamMember],
};
const result = teamReducer(initialTeamState, action);
expect(result.members).toEqual([mockTeamMember]);
});
it('should set member search query', () => {
const payload = 'member';
const action: Action = {
type: ActionTypes.SetSearchMemberQuery,
payload,
};
const result = teamReducer(initialTeamState, action);
expect(result.searchMemberQuery).toEqual('member');
});
});

View File

@@ -0,0 +1,44 @@
import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types';
import { Action, ActionTypes } from './actions';
export const initialTeamsState: TeamsState = { teams: [], searchQuery: '' };
export const initialTeamState: TeamState = {
team: {} as Team,
members: [] as TeamMember[],
groups: [] as TeamGroup[],
searchMemberQuery: '',
};
export const teamsReducer = (state = initialTeamsState, action: Action): TeamsState => {
switch (action.type) {
case ActionTypes.LoadTeams:
return { ...state, teams: action.payload };
case ActionTypes.SetSearchQuery:
return { ...state, searchQuery: action.payload };
}
return state;
};
export const teamReducer = (state = initialTeamState, action: Action): TeamState => {
switch (action.type) {
case ActionTypes.LoadTeam:
return { ...state, team: action.payload };
case ActionTypes.LoadTeamMembers:
return { ...state, members: action.payload };
case ActionTypes.SetSearchMemberQuery:
return { ...state, searchMemberQuery: action.payload };
case ActionTypes.LoadTeamGroups:
return { ...state, groups: action.payload };
}
return state;
};
export default {
teams: teamsReducer,
team: teamReducer,
};

View File

@@ -0,0 +1,56 @@
import { getTeam, getTeamMembers, getTeams } from './selectors';
import { getMockTeam, getMockTeamMembers, getMultipleMockTeams } from '../__mocks__/teamMocks';
import { Team, TeamGroup, TeamsState, TeamState } from '../../../types';
describe('Teams selectors', () => {
describe('Get teams', () => {
const mockTeams = getMultipleMockTeams(5);
it('should return teams if no search query', () => {
const mockState: TeamsState = { teams: mockTeams, searchQuery: '' };
const teams = getTeams(mockState);
expect(teams).toEqual(mockTeams);
});
it('Should filter teams if search query', () => {
const mockState: TeamsState = { teams: mockTeams, searchQuery: '5' };
const teams = getTeams(mockState);
expect(teams.length).toEqual(1);
});
});
});
describe('Team selectors', () => {
describe('Get team', () => {
const mockTeam = getMockTeam();
it('should return team if matching with location team', () => {
const mockState: TeamState = { team: mockTeam, searchMemberQuery: '', members: [], groups: [] };
const team = getTeam(mockState, '1');
expect(team).toEqual(mockTeam);
});
});
describe('Get members', () => {
const mockTeamMembers = getMockTeamMembers(5);
it('should return team members', () => {
const mockState: TeamState = {
team: {} as Team,
searchMemberQuery: '',
members: mockTeamMembers,
groups: [] as TeamGroup[],
};
const members = getTeamMembers(mockState);
expect(members).toEqual(mockTeamMembers);
});
});
});

View File

@@ -0,0 +1,30 @@
import { Team, TeamsState, TeamState } from 'app/types';
export const getSearchQuery = (state: TeamsState) => state.searchQuery;
export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery;
export const getTeamGroups = (state: TeamState) => state.groups;
export const getTeamsCount = (state: TeamsState) => state.teams.length;
export const getTeam = (state: TeamState, currentTeamId): Team | null => {
if (state.team.id === parseInt(currentTeamId, 10)) {
return state.team;
}
return null;
};
export const getTeams = (state: TeamsState) => {
const regex = RegExp(state.searchQuery, 'i');
return state.teams.filter(team => {
return regex.test(team.name);
});
};
export const getTeamMembers = (state: TeamState) => {
const regex = RegExp(state.searchMemberQuery, 'i');
return state.members.filter(member => {
return regex.test(member.login) || regex.test(member.email);
});
};

View File

@@ -39,7 +39,7 @@ export function addLabelToQuery(query: string, key: string, value: string): stri
// Add empty selector to bare metric name
let previousWord;
query = query.replace(/(\w+)\b(?![\(\]{=",])/g, (match, word, offset) => {
query = query.replace(/([A-Za-z]\w*)\b(?![\(\]{=",])/g, (match, word, offset) => {
// Check if inside a selector
const nextSelectorStart = query.slice(offset).indexOf('{');
const nextSelectorEnd = query.slice(offset).indexOf('}');

View File

@@ -376,6 +376,7 @@ describe('PrometheusDatasource', () => {
'foo{bar="baz",instance="my-host.com:9100"}'
);
expect(addLabelToQuery('rate(metric[1m])', 'foo', 'bar')).toBe('rate(metric{foo="bar"}[1m])');
expect(addLabelToQuery('metric > 0.001', 'foo', 'bar')).toBe('metric{foo="bar"} > 0.001');
});
});

View File

@@ -4,9 +4,9 @@ import './ReactContainer';
import ServerStats from 'app/features/admin/ServerStats';
import AlertRuleList from 'app/features/alerting/AlertRuleList';
import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
import TeamPages from 'app/features/teams/TeamPages';
import TeamList from 'app/features/teams/TeamList';
import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
import TeamPages from 'app/containers/Teams/TeamPages';
import TeamList from 'app/containers/Teams/TeamList';
/** @ngInject */
export function setupAngularRoutes($routeProvider, $locationProvider) {

View File

@@ -1,7 +1,6 @@
import _ from 'lodash';
import { types, getEnv } from 'mobx-state-tree';
import { NavItem } from './NavItem';
import { Team } from '../TeamsStore/TeamsStore';
export const NavStore = types
.model('NavStore', {
@@ -116,43 +115,4 @@ export const NavStore = types
self.main = NavItem.create(main);
},
initTeamPage(team: Team, tab: string, isSyncEnabled: boolean) {
const main = {
img: team.avatarUrl,
id: 'team-' + team.id,
subTitle: 'Manage members & settings',
url: '',
text: team.name,
breadcrumbs: [{ title: 'Teams', url: 'org/teams' }],
children: [
{
active: tab === 'members',
icon: 'gicon gicon-team',
id: 'team-members',
text: 'Members',
url: `org/teams/edit/${team.id}/members`,
},
{
active: tab === 'settings',
icon: 'fa fa-fw fa-sliders',
id: 'team-settings',
text: 'Settings',
url: `org/teams/edit/${team.id}/settings`,
},
],
};
if (isSyncEnabled) {
main.children.splice(1, 0, {
active: tab === 'groupsync',
icon: 'fa fa-fw fa-refresh',
id: 'team-settings',
text: 'External group sync',
url: `org/teams/edit/${team.id}/groupsync`,
});
}
self.main = NavItem.create(main);
},
}));

View File

@@ -3,7 +3,6 @@ import { NavStore } from './../NavStore/NavStore';
import { ViewStore } from './../ViewStore/ViewStore';
import { FolderStore } from './../FolderStore/FolderStore';
import { PermissionsStore } from './../PermissionsStore/PermissionsStore';
import { TeamsStore } from './../TeamsStore/TeamsStore';
export const RootStore = types.model({
nav: types.optional(NavStore, {}),
@@ -17,9 +16,6 @@ export const RootStore = types.model({
routeParams: {},
}),
folder: types.optional(FolderStore, {}),
teams: types.optional(TeamsStore, {
map: {},
}),
});
type RootStoreType = typeof RootStore.Type;

View File

@@ -1,156 +0,0 @@
import { types, getEnv, flow } from 'mobx-state-tree';
export const TeamMemberModel = types.model('TeamMember', {
userId: types.identifier(types.number),
teamId: types.number,
avatarUrl: types.string,
email: types.string,
login: types.string,
});
type TeamMemberType = typeof TeamMemberModel.Type;
export interface TeamMember extends TeamMemberType {}
export const TeamGroupModel = types.model('TeamGroup', {
groupId: types.identifier(types.string),
teamId: types.number,
});
type TeamGroupType = typeof TeamGroupModel.Type;
export interface TeamGroup extends TeamGroupType {}
export const TeamModel = types
.model('Team', {
id: types.identifier(types.number),
name: types.string,
avatarUrl: types.string,
email: types.string,
memberCount: types.number,
search: types.optional(types.string, ''),
members: types.optional(types.map(TeamMemberModel), {}),
groups: types.optional(types.map(TeamGroupModel), {}),
})
.views(self => ({
get filteredMembers(this: Team) {
const members = this.members.values();
const regex = new RegExp(self.search, 'i');
return members.filter(member => {
return regex.test(member.login) || regex.test(member.email);
});
},
}))
.actions(self => ({
setName(name: string) {
self.name = name;
},
setEmail(email: string) {
self.email = email;
},
setSearchQuery(query: string) {
self.search = query;
},
update: flow(function* load() {
const backendSrv = getEnv(self).backendSrv;
yield backendSrv.put(`/api/teams/${self.id}`, {
name: self.name,
email: self.email,
});
}),
loadMembers: flow(function* load() {
const backendSrv = getEnv(self).backendSrv;
const rsp = yield backendSrv.get(`/api/teams/${self.id}/members`);
self.members.clear();
for (const member of rsp) {
self.members.set(member.userId.toString(), TeamMemberModel.create(member));
}
}),
removeMember: flow(function* load(member: TeamMember) {
const backendSrv = getEnv(self).backendSrv;
yield backendSrv.delete(`/api/teams/${self.id}/members/${member.userId}`);
// remove from store map
self.members.delete(member.userId.toString());
}),
addMember: flow(function* load(userId: number) {
const backendSrv = getEnv(self).backendSrv;
yield backendSrv.post(`/api/teams/${self.id}/members`, { userId: userId });
}),
loadGroups: flow(function* load() {
const backendSrv = getEnv(self).backendSrv;
const rsp = yield backendSrv.get(`/api/teams/${self.id}/groups`);
self.groups.clear();
for (const group of rsp) {
self.groups.set(group.groupId, TeamGroupModel.create(group));
}
}),
addGroup: flow(function* load(groupId: string) {
const backendSrv = getEnv(self).backendSrv;
yield backendSrv.post(`/api/teams/${self.id}/groups`, { groupId: groupId });
self.groups.set(
groupId,
TeamGroupModel.create({
teamId: self.id,
groupId: groupId,
})
);
}),
removeGroup: flow(function* load(groupId: string) {
const backendSrv = getEnv(self).backendSrv;
yield backendSrv.delete(`/api/teams/${self.id}/groups/${groupId}`);
self.groups.delete(groupId);
}),
}));
type TeamType = typeof TeamModel.Type;
export interface Team extends TeamType {}
export const TeamsStore = types
.model('TeamsStore', {
map: types.map(TeamModel),
search: types.optional(types.string, ''),
})
.views(self => ({
get filteredTeams(this: any) {
const teams = this.map.values();
const regex = new RegExp(self.search, 'i');
return teams.filter(team => {
return regex.test(team.name);
});
},
}))
.actions(self => ({
loadTeams: flow(function* load() {
const backendSrv = getEnv(self).backendSrv;
const rsp = yield backendSrv.get('/api/teams/search/', { perpage: 50, page: 1 });
self.map.clear();
for (const team of rsp.teams) {
self.map.set(team.id.toString(), TeamModel.create(team));
}
}),
setSearchQuery(query: string) {
self.search = query;
},
loadById: flow(function* load(id: string) {
if (self.map.has(id)) {
return;
}
const backendSrv = getEnv(self).backendSrv;
const team = yield backendSrv.get(`/api/teams/${id}`);
self.map.set(id, TeamModel.create(team));
}),
}));

View File

@@ -3,10 +3,12 @@ import thunk from 'redux-thunk';
import { createLogger } from 'redux-logger';
import sharedReducers from 'app/core/reducers';
import alertingReducers from 'app/features/alerting/state/reducers';
import teamsReducers from 'app/features/teams/state/reducers';
const rootReducer = combineReducers({
...sharedReducers,
...alertingReducers,
...teamsReducers,
});
export let store;

View File

@@ -0,0 +1,35 @@
export interface AlertRuleDTO {
id: number;
dashboardId: number;
dashboardUid: string;
dashboardSlug: string;
panelId: number;
name: string;
state: string;
newStateDate: string;
evalDate: string;
evalData?: object;
executionError: string;
url: string;
}
export interface AlertRule {
id: number;
dashboardId: number;
panelId: number;
name: string;
state: string;
stateText: string;
stateIcon: string;
stateClass: string;
stateAge: string;
url: string;
info?: string;
executionError?: string;
evalData?: { noData: boolean };
}
export interface AlertRulesState {
items: AlertRule[];
searchQuery: string;
}

View File

@@ -1,96 +1,30 @@
//
// Location
//
import { Team, TeamsState, TeamState, TeamGroup, TeamMember } from './teams';
import { AlertRuleDTO, AlertRule, AlertRulesState } from './alerting';
import { LocationState, LocationUpdate, UrlQueryMap, UrlQueryValue } from './location';
import { NavModel, NavModelItem, NavIndex } from './navModel';
export interface LocationUpdate {
path?: string;
query?: UrlQueryMap;
routeParams?: UrlQueryMap;
}
export interface LocationState {
url: string;
path: string;
query: UrlQueryMap;
routeParams: UrlQueryMap;
}
export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[];
export type UrlQueryMap = { [s: string]: UrlQueryValue };
//
// Alerting
//
export interface AlertRuleApi {
id: number;
dashboardId: number;
dashboardUid: string;
dashboardSlug: string;
panelId: number;
name: string;
state: string;
newStateDate: string;
evalDate: string;
evalData?: object;
executionError: string;
url: string;
}
export interface AlertRule {
id: number;
dashboardId: number;
panelId: number;
name: string;
state: string;
stateText: string;
stateIcon: string;
stateClass: string;
stateAge: string;
url: string;
info?: string;
executionError?: string;
evalData?: { noData: boolean };
}
//
// NavModel
//
export interface NavModelItem {
text: string;
url: string;
subTitle?: string;
icon?: string;
img?: string;
id: string;
active?: boolean;
hideFromTabs?: boolean;
divider?: boolean;
children?: NavModelItem[];
breadcrumbs?: NavModelItem[];
target?: string;
parentItem?: NavModelItem;
}
export interface NavModel {
main: NavModelItem;
node: NavModelItem;
}
export type NavIndex = { [s: string]: NavModelItem };
//
// Store
//
export interface AlertRulesState {
items: AlertRule[];
searchQuery: string;
}
export {
Team,
TeamsState,
TeamState,
TeamGroup,
TeamMember,
AlertRuleDTO,
AlertRule,
AlertRulesState,
LocationState,
LocationUpdate,
NavModel,
NavModelItem,
NavIndex,
UrlQueryMap,
UrlQueryValue,
};
export interface StoreState {
navIndex: NavIndex;
location: LocationState;
alertRules: AlertRulesState;
teams: TeamsState;
team: TeamState;
}

View File

@@ -0,0 +1,15 @@
export interface LocationUpdate {
path?: string;
query?: UrlQueryMap;
routeParams?: UrlQueryMap;
}
export interface LocationState {
url: string;
path: string;
query: UrlQueryMap;
routeParams: UrlQueryMap;
}
export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[];
export type UrlQueryMap = { [s: string]: UrlQueryValue };

View File

@@ -0,0 +1,22 @@
export interface NavModelItem {
text: string;
url: string;
subTitle?: string;
icon?: string;
img?: string;
id: string;
active?: boolean;
hideFromTabs?: boolean;
divider?: boolean;
children?: NavModelItem[];
breadcrumbs?: Array<{ title: string; url: string }>;
target?: string;
parentItem?: NavModelItem;
}
export interface NavModel {
main: NavModelItem;
node: NavModelItem;
}
export type NavIndex = { [s: string]: NavModelItem };

32
public/app/types/teams.ts Normal file
View File

@@ -0,0 +1,32 @@
export interface Team {
id: number;
name: string;
avatarUrl: string;
email: string;
memberCount: number;
}
export interface TeamMember {
userId: number;
teamId: number;
avatarUrl: string;
email: string;
login: string;
}
export interface TeamGroup {
groupId: string;
teamId: number;
}
export interface TeamsState {
teams: Team[];
searchQuery: string;
}
export interface TeamState {
team: Team;
members: TeamMember[];
groups: TeamGroup[];
searchMemberQuery: string;
}

View File

@@ -7,7 +7,7 @@ module.exports = function(config, grunt) {
}
return {
tslint: 'node ./node_modules/tslint/lib/tslint-cli.js -c tslint.json --project ./tsconfig.json',
tslint: 'node ./node_modules/tslint/lib/tslintCli.js -c tslint.json --project ./tsconfig.json',
jest: 'node ./node_modules/jest-cli/bin/jest.js ' + coverage,
webpack: 'node ./node_modules/webpack/bin/webpack.js --config scripts/webpack/webpack.prod.js',
};

3173
yarn.lock

File diff suppressed because it is too large Load Diff