merge master

This commit is contained in:
ryan
2019-03-19 09:26:15 -07:00
110 changed files with 4316 additions and 2242 deletions

View File

@@ -4,13 +4,16 @@ jest.mock('app/core/store', () => {
};
});
// @ts-ignore
import _ from 'lodash';
import config from 'app/core/config';
import { DashboardExporter } from './DashboardExporter';
import { DashboardModel } from '../../state/DashboardModel';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import { PanelPlugin } from 'app/types';
describe('given dashboard with repeated panels', () => {
let dash, exported;
let dash: any, exported: any;
beforeEach(done => {
dash = {
@@ -89,25 +92,25 @@ describe('given dashboard with repeated panels', () => {
config.buildInfo.version = '3.0.2';
//Stubs test function calls
const datasourceSrvStub = { get: jest.fn(arg => getStub(arg)) };
const datasourceSrvStub = ({ get: jest.fn(arg => getStub(arg)) } as any) as DatasourceSrv;
config.panels['graph'] = {
id: 'graph',
name: 'Graph',
info: { version: '1.1.0' },
};
} as PanelPlugin;
config.panels['table'] = {
id: 'table',
name: 'Table',
info: { version: '1.1.1' },
};
} as PanelPlugin;
config.panels['heatmap'] = {
id: 'heatmap',
name: 'Heatmap',
info: { version: '1.1.2' },
};
} as PanelPlugin;
dash = new DashboardModel(dash, {});
const exporter = new DashboardExporter(datasourceSrvStub);
@@ -213,7 +216,7 @@ describe('given dashboard with repeated panels', () => {
});
// Stub responses
const stubs = [];
const stubs: { [key: string]: {} } = {};
stubs['gfdb'] = {
name: 'gfdb',
meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' },
@@ -249,6 +252,6 @@ stubs['-- Grafana --'] = {
},
};
function getStub(arg) {
function getStub(arg: string) {
return Promise.resolve(stubs[arg || 'gfdb']);
}

View File

@@ -1,9 +1,42 @@
import config from 'app/core/config';
// @ts-ignore
import _ from 'lodash';
import config from 'app/core/config';
import { DashboardModel } from '../../state/DashboardModel';
import DatasourceSrv from 'app/features/plugins/datasource_srv';
import { PanelModel } from 'app/features/dashboard/state';
import { PanelPlugin } from 'app/types/plugins';
interface Input {
name: string;
type: string;
label: string;
value: any;
description: string;
}
interface Requires {
[key: string]: {
type: string;
id: string;
name: string;
version: string;
};
}
interface DataSources {
[key: string]: {
name: string;
label: string;
description: string;
type: string;
pluginId: string;
pluginName: string;
};
}
export class DashboardExporter {
constructor(private datasourceSrv) {}
constructor(private datasourceSrv: DatasourceSrv) {}
makeExportable(dashboard: DashboardModel) {
// clean up repeated rows and panels,
@@ -18,19 +51,19 @@ export class DashboardExporter {
// undo repeat cleanup
dashboard.processRepeats();
const inputs = [];
const requires = {};
const datasources = {};
const promises = [];
const variableLookup: any = {};
const inputs: Input[] = [];
const requires: Requires = {};
const datasources: DataSources = {};
const promises: Array<Promise<void>> = [];
const variableLookup: { [key: string]: any } = {};
for (const variable of saveModel.templating.list) {
variableLookup[variable.name] = variable;
}
const templateizeDatasourceUsage = obj => {
let datasource = obj.datasource;
let datasourceVariable = null;
const templateizeDatasourceUsage = (obj: any) => {
let datasource: string = obj.datasource;
let datasourceVariable: any = null;
// ignore data source properties that contain a variable
if (datasource && datasource.indexOf('$') === 0) {
@@ -74,7 +107,7 @@ export class DashboardExporter {
);
};
const processPanel = panel => {
const processPanel = (panel: PanelModel) => {
if (panel.datasource !== undefined) {
templateizeDatasourceUsage(panel);
}
@@ -87,7 +120,7 @@ export class DashboardExporter {
}
}
const panelDef = config.panels[panel.type];
const panelDef: PanelPlugin = config.panels[panel.type];
if (panelDef) {
requires['panel' + panelDef.id] = {
type: 'panel',
@@ -135,7 +168,7 @@ export class DashboardExporter {
return Promise.all(promises)
.then(() => {
_.each(datasources, (value, key) => {
_.each(datasources, (value: any) => {
inputs.push(value);
});
@@ -160,7 +193,7 @@ export class DashboardExporter {
}
// make inputs and requires a top thing
const newObj = {};
const newObj: { [key: string]: {} } = {};
newObj['__inputs'] = inputs;
newObj['__requires'] = _.sortBy(requires, ['id']);

View File

@@ -5,17 +5,12 @@ import React, { PureComponent, ChangeEvent, FocusEvent } from 'react';
import { isValidTimeSpan } from 'app/core/utils/rangeutil';
// Components
import { Switch } from '@grafana/ui';
import { Input } from 'app/core/components/Form';
import { EventsWithValidation } from 'app/core/components/Form/Input';
import { InputStatus } from 'app/core/components/Form/Input';
import { DataSourceSelectItem, EventsWithValidation, Input, InputStatus, Switch, ValidationEvents } from '@grafana/ui';
import { DataSourceOption } from './DataSourceOption';
import { FormLabel } from '@grafana/ui';
// Types
import { PanelModel } from '../state/PanelModel';
import { DataSourceSelectItem } from '@grafana/ui/src/types';
import { ValidationEvents } from 'app/types';
import { PanelModel } from '../state';
const timeRangeValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [

View File

@@ -3,9 +3,10 @@ import { PanelModel } from './PanelModel';
describe('PanelModel', () => {
describe('when creating new panel model', () => {
let model;
let modelJson;
beforeEach(() => {
model = new PanelModel({
modelJson = {
type: 'table',
showColumns: true,
targets: [{ refId: 'A' }, { noRefId: true }],
@@ -23,7 +24,8 @@ describe('PanelModel', () => {
},
],
},
});
};
model = new PanelModel(modelJson);
});
it('should apply defaults', () => {
@@ -38,6 +40,15 @@ describe('PanelModel', () => {
expect(model.targets[1].refId).toBe('B');
});
it("shouldn't break panel with non-array targets", () => {
modelJson.targets = {
0: { refId: 'A' },
foo: { bar: 'baz' },
};
model = new PanelModel(modelJson);
expect(model.targets[0].refId).toBe('A');
});
it('getSaveModel should remove defaults', () => {
const saveModel = model.getSaveModel();
expect(saveModel.gridPos).toBe(undefined);

View File

@@ -1,8 +1,11 @@
// Libraries
import _ from 'lodash';
// Types
// Utils
import { Emitter } from 'app/core/utils/emitter';
import { getNextRefIdChar } from 'app/core/utils/query';
// Types
import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui';
import { TableData } from '@grafana/ui/src';
@@ -125,10 +128,10 @@ export class PanelModel {
}
ensureQueryIds() {
if (this.targets) {
if (this.targets && _.isArray(this.targets)) {
for (const query of this.targets) {
if (!query.refId) {
query.refId = this.getNextQueryLetter();
query.refId = getNextRefIdChar(this.targets);
}
}
}
@@ -266,20 +269,10 @@ export class PanelModel {
addQuery(query?: Partial<DataQuery>) {
query = query || { refId: 'A' };
query.refId = this.getNextQueryLetter();
query.refId = getNextRefIdChar(this.targets);
this.targets.push(query as DataQuery);
}
getNextQueryLetter(): string {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return _.find(letters, refId => {
return _.every(this.targets, other => {
return other.refId !== refId;
});
});
}
changeQuery(query: DataQuery, index: number) {
// ensure refId is maintained
query.refId = this.targets[index].refId;

View File

@@ -60,7 +60,6 @@ import {
splitCloseAction,
splitOpenAction,
addQueryRowAction,
AddQueryRowPayload,
toggleGraphAction,
toggleLogsAction,
toggleTableAction,
@@ -87,9 +86,12 @@ const updateExploreUIState = (exploreId, uiStateFragment: Partial<ExploreUIState
/**
* Adds a query row after the row with the given index.
*/
export function addQueryRow(exploreId: ExploreId, index: number): ActionOf<AddQueryRowPayload> {
const query = generateEmptyQuery(index + 1);
return addQueryRowAction({ exploreId, index, query });
export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
return (dispatch, getState) => {
const query = generateEmptyQuery(getState().explore[exploreId].queries, index);
dispatch(addQueryRowAction({ exploreId, index, query }));
};
}
/**
@@ -126,10 +128,10 @@ export function changeQuery(
index: number,
override: boolean
): ThunkResult<void> {
return dispatch => {
return (dispatch, getState) => {
// Null query means reset
if (query === null) {
query = { ...generateEmptyQuery(index) };
query = { ...generateEmptyQuery(getState().explore[exploreId].queries) };
}
dispatch(changeQueryAction({ exploreId, query, index, override }));
@@ -287,7 +289,7 @@ export function importQueries(
const nextQueries = importedQueries.map((q, i) => ({
...q,
...generateEmptyQuery(i),
...generateEmptyQuery(queries),
}));
dispatch(queriesImportedAction({ exploreId, queries: nextQueries }));
@@ -629,9 +631,9 @@ export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkRes
* Use this action for clicks on query examples. Triggers a query run.
*/
export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult<void> {
return dispatch => {
return (dispatch, getState) => {
// Inject react keys into query objects
const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() }));
const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery(getState().explore[exploreId].queries) }));
dispatch(setQueriesAction({ exploreId, queries }));
dispatch(runQueries(exploreId));
};

View File

@@ -127,7 +127,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
const { query, index } = action.payload;
// Override path: queries are completely reset
const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(index) };
const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(state.queries) };
const nextQueries = [...queries];
nextQueries[index] = nextQuery;
@@ -267,7 +267,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
// Modify all queries
nextQueries = queries.map((query, i) => ({
...modifier({ ...query }, modification),
...generateEmptyQuery(i),
...generateEmptyQuery(state.queries),
}));
// Discard all ongoing transactions
nextQueryTransactions = [];
@@ -276,7 +276,9 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
nextQueries = queries.map((query, i) => {
// Synchronize all queries with local query cache to ensure consistency
// TODO still needed?
return i === index ? { ...modifier({ ...query }, modification), ...generateEmptyQuery(i) } : query;
return i === index
? { ...modifier({ ...query }, modification), ...generateEmptyQuery(state.queries) }
: query;
});
nextQueryTransactions = queryTransactions
// Consume the hint corresponding to the action

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Props, TeamList } from './TeamList';
import { NavModel, Team } from '../../types';
import { NavModel, Team, OrgRole } from '../../types';
import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
import { User } from 'app/core/services/context_srv';
const setup = (propOverrides?: object) => {
const props: Props = {
@@ -21,6 +22,11 @@ const setup = (propOverrides?: object) => {
searchQuery: '',
teamsCount: 0,
hasFetched: false,
editorsCanAdmin: false,
signedInUser: {
id: 1,
orgRole: OrgRole.Viewer,
} as User,
};
Object.assign(props, propOverrides);
@@ -49,6 +55,42 @@ describe('Render', () => {
expect(wrapper).toMatchSnapshot();
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
describe('and signedin user is not viewer', () => {
it('should enable the new team button', () => {
const { wrapper } = setup({
teams: getMultipleMockTeams(1),
teamsCount: 1,
hasFetched: true,
editorsCanAdmin: true,
signedInUser: {
id: 1,
orgRole: OrgRole.Editor,
} as User,
});
expect(wrapper).toMatchSnapshot();
});
});
describe('and signedin user is a viewer', () => {
it('should disable the new team button', () => {
const { wrapper } = setup({
teams: getMultipleMockTeams(1),
teamsCount: 1,
hasFetched: true,
editorsCanAdmin: true,
signedInUser: {
id: 1,
orgRole: OrgRole.Viewer,
} as User,
});
expect(wrapper).toMatchSnapshot();
});
});
});
});
describe('Life cycle', () => {

View File

@@ -4,11 +4,13 @@ import { hot } from 'react-hot-loader';
import Page from 'app/core/components/Page/Page';
import { DeleteButton } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { NavModel, Team } from 'app/types';
import { NavModel, Team, OrgRole } from 'app/types';
import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors';
import { getNavModel } from 'app/core/selectors/navModel';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { config } from 'app/core/config';
import { contextSrv, User } from 'app/core/services/context_srv';
export interface Props {
navModel: NavModel;
@@ -19,6 +21,8 @@ export interface Props {
loadTeams: typeof loadTeams;
deleteTeam: typeof deleteTeam;
setSearchQuery: typeof setSearchQuery;
editorsCanAdmin?: boolean;
signedInUser?: User;
}
export class TeamList extends PureComponent<Props, any> {
@@ -39,7 +43,10 @@ export class TeamList extends PureComponent<Props, any> {
};
renderTeam(team: Team) {
const { editorsCanAdmin, signedInUser } = this.props;
const permission = team.permission;
const teamUrl = `org/teams/edit/${team.id}`;
const canDelete = isPermissionTeamAdmin({ permission, editorsCanAdmin, signedInUser });
return (
<tr key={team.id}>
@@ -58,7 +65,7 @@ export class TeamList extends PureComponent<Props, any> {
<a href={teamUrl}>{team.memberCount}</a>
</td>
<td className="text-right">
<DeleteButton onConfirm={() => this.deleteTeam(team)} />
<DeleteButton onConfirm={() => this.deleteTeam(team)} disabled={!canDelete} />
</td>
</tr>
);
@@ -84,7 +91,10 @@ export class TeamList extends PureComponent<Props, any> {
}
renderTeamList() {
const { teams, searchQuery } = this.props;
const { teams, searchQuery, editorsCanAdmin, signedInUser } = this.props;
const isCanAdminAndViewer = editorsCanAdmin && signedInUser.orgRole === OrgRole.Viewer;
const disabledClass = isCanAdminAndViewer ? ' disabled' : '';
const newTeamHref = isCanAdminAndViewer ? '#' : 'org/teams/new';
return (
<>
@@ -101,7 +111,7 @@ export class TeamList extends PureComponent<Props, any> {
<div className="page-action-bar__spacer" />
<a className="btn btn-primary" href="org/teams/new">
<a className={`btn btn-primary${disabledClass}`} href={newTeamHref}>
New team
</a>
</div>
@@ -152,6 +162,8 @@ function mapStateToProps(state) {
searchQuery: getSearchQuery(state.teams),
teamsCount: getTeamsCount(state.teams),
hasFetched: state.teams.hasFetched,
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
};
}

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TeamMember, TeamPermissionLevel } from '../../types';
import { getMockTeamMember } from './__mocks__/teamMocks';
import { TeamMemberRow, Props } from './TeamMemberRow';
import { SelectOptionItem } from '@grafana/ui';
const setup = (propOverrides?: object) => {
const props: Props = {
member: getMockTeamMember(),
syncEnabled: false,
editorsCanAdmin: false,
signedInUserIsTeamAdmin: false,
updateTeamMember: jest.fn(),
removeTeamMember: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = shallow(<TeamMemberRow {...props} />);
const instance = wrapper.instance() as TeamMemberRow;
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render team members when sync enabled', () => {
const member = getMockTeamMember();
member.labels = ['LDAP'];
const { wrapper } = setup({ member, syncEnabled: true });
expect(wrapper).toMatchSnapshot();
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
it('should render permissions select if user is team admin', () => {
const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: true });
expect(wrapper).toMatchSnapshot();
});
it('should render span and disable buttons if user is team member', () => {
const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: false });
expect(wrapper).toMatchSnapshot();
});
});
describe('when feature toggle editorsCanAdmin is turned off', () => {
it('should not render permissions', () => {
const { wrapper } = setup({ editorsCanAdmin: false, signedInUserIsTeamAdmin: true });
expect(wrapper).toMatchSnapshot();
});
});
});
describe('Functions', () => {
describe('on remove member', () => {
const member = getMockTeamMember();
const { instance } = setup({ member });
instance.onRemoveMember(member);
expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1);
});
describe('on update permision for user in team', () => {
const member: TeamMember = {
userId: 3,
teamId: 2,
avatarUrl: '',
email: 'user@user.org',
labels: [],
login: 'member',
permission: TeamPermissionLevel.Member,
};
const { instance } = setup({ member });
const permission = TeamPermissionLevel.Admin;
const item: SelectOptionItem = { value: permission };
const expectedTeamMemeber = { ...member, permission };
instance.onPermissionChange(item, member);
expect(instance.props.updateTeamMember).toHaveBeenCalledWith(expectedTeamMemeber);
});
});

View File

@@ -0,0 +1,106 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { DeleteButton, Select, SelectOptionItem } from '@grafana/ui';
import { TeamMember, teamsPermissionLevels } from 'app/types';
import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle';
import { updateTeamMember, removeTeamMember } from './state/actions';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
export interface Props {
member: TeamMember;
syncEnabled: boolean;
editorsCanAdmin: boolean;
signedInUserIsTeamAdmin: boolean;
removeTeamMember?: typeof removeTeamMember;
updateTeamMember?: typeof updateTeamMember;
}
export class TeamMemberRow extends PureComponent<Props> {
constructor(props: Props) {
super(props);
this.renderLabels = this.renderLabels.bind(this);
this.renderPermissions = this.renderPermissions.bind(this);
}
onRemoveMember(member: TeamMember) {
this.props.removeTeamMember(member.userId);
}
onPermissionChange = (item: SelectOptionItem, member: TeamMember) => {
const permission = item.value;
const updatedTeamMember = { ...member, permission };
this.props.updateTeamMember(updatedTeamMember);
};
renderPermissions(member: TeamMember) {
const { editorsCanAdmin, signedInUserIsTeamAdmin } = this.props;
const value = teamsPermissionLevels.find(dp => dp.value === member.permission);
return (
<WithFeatureToggle featureToggle={editorsCanAdmin}>
<td className="width-5 team-permissions">
<div className="gf-form">
{signedInUserIsTeamAdmin && (
<Select
isSearchable={false}
options={teamsPermissionLevels}
onChange={item => this.onPermissionChange(item, member)}
className="gf-form-select-box__control--menu-right"
value={value}
/>
)}
{!signedInUserIsTeamAdmin && <span>{value.label}</span>}
</div>
</td>
</WithFeatureToggle>
);
}
renderLabels(labels: string[]) {
if (!labels) {
return <td />;
}
return (
<td>
{labels.map(label => (
<TagBadge key={label} label={label} removeIcon={false} count={0} onClick={() => {}} />
))}
</td>
);
}
render() {
const { member, syncEnabled, signedInUserIsTeamAdmin } = this.props;
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>
{this.renderPermissions(member)}
{syncEnabled && this.renderLabels(member.labels)}
<td className="text-right">
<DeleteButton onConfirm={() => this.onRemoveMember(member)} disabled={!signedInUserIsTeamAdmin} />
</td>
</tr>
);
}
}
function mapStateToProps(state) {
return {};
}
const mapDispatchToProps = {
removeTeamMember,
updateTeamMember,
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(TeamMemberRow);

View File

@@ -1,18 +1,25 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TeamMembers, Props, State } from './TeamMembers';
import { TeamMember } from '../../types';
import { getMockTeamMember, getMockTeamMembers } from './__mocks__/teamMocks';
import { TeamMember, OrgRole } from '../../types';
import { getMockTeamMembers } from './__mocks__/teamMocks';
import { User } from 'app/core/services/context_srv';
const signedInUserId = 1;
const setup = (propOverrides?: object) => {
const props: Props = {
members: [] as TeamMember[],
searchMemberQuery: '',
setSearchMemberQuery: jest.fn(),
loadTeamMembers: jest.fn(),
addTeamMember: jest.fn(),
removeTeamMember: jest.fn(),
syncEnabled: false,
editorsCanAdmin: false,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
};
Object.assign(props, propOverrides);
@@ -28,24 +35,13 @@ const setup = (propOverrides?: object) => {
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
const { wrapper } = setup({});
expect(wrapper).toMatchSnapshot();
});
it('should render team members', () => {
const { wrapper } = setup({
members: getMockTeamMembers(5),
});
expect(wrapper).toMatchSnapshot();
});
it('should render team members when sync enabled', () => {
const { wrapper } = setup({
members: getMockTeamMembers(5),
syncEnabled: true,
});
const { wrapper } = setup({ members: getMockTeamMembers(5, 5) });
expect(wrapper).toMatchSnapshot();
});
@@ -54,7 +50,7 @@ describe('Render', () => {
describe('Functions', () => {
describe('on search member query change', () => {
it('it should call setSearchMemberQuery', () => {
const { instance } = setup();
const { instance } = setup({});
instance.onSearchQueryChange('member');
@@ -62,17 +58,8 @@ describe('Functions', () => {
});
});
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 { wrapper, instance } = setup({});
const state = wrapper.state() as State;
state.newTeamMember = {

View File

@@ -2,21 +2,24 @@ import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import SlideDown from 'app/core/components/Animations/SlideDown';
import { UserPicker } from 'app/core/components/Select/UserPicker';
import { DeleteButton } from '@grafana/ui';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { TeamMember, User } from 'app/types';
import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions';
import { getSearchMemberQuery, getTeamMembers } from './state/selectors';
import { addTeamMember, setSearchMemberQuery } from './state/actions';
import { getSearchMemberQuery, isSignedInUserTeamAdmin } from './state/selectors';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle';
import { config } from 'app/core/config';
import { contextSrv, User as SignedInUser } from 'app/core/services/context_srv';
import TeamMemberRow from './TeamMemberRow';
export interface Props {
members: TeamMember[];
searchMemberQuery: string;
loadTeamMembers: typeof loadTeamMembers;
addTeamMember: typeof addTeamMember;
removeTeamMember: typeof removeTeamMember;
setSearchMemberQuery: typeof setSearchMemberQuery;
syncEnabled: boolean;
editorsCanAdmin?: boolean;
signedInUser?: SignedInUser;
}
export interface State {
@@ -30,18 +33,10 @@ export class TeamMembers extends PureComponent<Props, State> {
this.state = { isAdding: false, newTeamMember: null };
}
componentDidMount() {
this.props.loadTeamMembers();
}
onSearchQueryChange = (value: string) => {
this.props.setSearchMemberQuery(value);
};
onRemoveMember(member: TeamMember) {
this.props.removeTeamMember(member.userId);
}
onToggleAdding = () => {
this.setState({ isAdding: !this.state.isAdding });
};
@@ -69,25 +64,11 @@ export class TeamMembers extends PureComponent<Props, State> {
);
}
renderMember(member: TeamMember, syncEnabled: boolean) {
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>
{syncEnabled && this.renderLabels(member.labels)}
<td className="text-right">
<DeleteButton onConfirm={() => this.onRemoveMember(member)} />
</td>
</tr>
);
}
render() {
const { isAdding } = this.state;
const { searchMemberQuery, members, syncEnabled } = this.props;
const { searchMemberQuery, members, syncEnabled, editorsCanAdmin, signedInUser } = this.props;
const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser });
return (
<div>
<div className="page-action-bar">
@@ -103,7 +84,11 @@ export class TeamMembers extends PureComponent<Props, State> {
<div className="page-action-bar__spacer" />
<button className="btn btn-primary pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
<button
className="btn btn-primary pull-right"
onClick={this.onToggleAdding}
disabled={isAdding || !isTeamAdmin}
>
Add member
</button>
</div>
@@ -132,11 +117,25 @@ export class TeamMembers extends PureComponent<Props, State> {
<th />
<th>Name</th>
<th>Email</th>
<WithFeatureToggle featureToggle={editorsCanAdmin}>
<th>Permission</th>
</WithFeatureToggle>
{syncEnabled && <th />}
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>{members && members.map(member => this.renderMember(member, syncEnabled))}</tbody>
<tbody>
{members &&
members.map(member => (
<TeamMemberRow
key={member.userId}
member={member}
syncEnabled={syncEnabled}
editorsCanAdmin={editorsCanAdmin}
signedInUserIsTeamAdmin={isTeamAdmin}
/>
))}
</tbody>
</table>
</div>
</div>
@@ -146,15 +145,14 @@ export class TeamMembers extends PureComponent<Props, State> {
function mapStateToProps(state) {
return {
members: getTeamMembers(state.team),
searchMemberQuery: getSearchMemberQuery(state.team),
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
};
}
const mapDispatchToProps = {
loadTeamMembers,
addTeamMember,
removeTeamMember,
setSearchMemberQuery,
};

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TeamPages, Props } from './TeamPages';
import { NavModel, Team } from '../../types';
import { NavModel, Team, TeamMember, OrgRole } from '../../types';
import { getMockTeam } from './__mocks__/teamMocks';
import { User } from 'app/core/services/context_srv';
jest.mock('app/core/config', () => ({
buildInfo: { isEnterprise: true },
@@ -13,8 +14,16 @@ const setup = (propOverrides?: object) => {
navModel: {} as NavModel,
teamId: 1,
loadTeam: jest.fn(),
loadTeamMembers: jest.fn(),
pageName: 'members',
team: {} as Team,
members: [] as TeamMember[],
editorsCanAdmin: false,
signedInUser: {
id: 1,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
};
Object.assign(props, propOverrides);
@@ -65,4 +74,46 @@ describe('Render', () => {
expect(wrapper).toMatchSnapshot();
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
it('should render settings page if user is team admin', () => {
const { wrapper } = setup({
team: getMockTeam(),
pageName: 'settings',
preferences: {
homeDashboardId: 1,
theme: 'Default',
timezone: 'Default',
},
editorsCanAdmin: true,
signedInUser: {
id: 1,
isGrafanaAdmin: false,
orgRole: OrgRole.Admin,
} as User,
});
expect(wrapper).toMatchSnapshot();
});
it('should not render settings page if user is team member', () => {
const { wrapper } = setup({
team: getMockTeam(),
pageName: 'settings',
preferences: {
homeDashboardId: 1,
theme: 'Default',
timezone: 'Default',
},
editorsCanAdmin: true,
signedInUser: {
id: 1,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
});
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@@ -7,19 +7,24 @@ import Page from 'app/core/components/Page/Page';
import TeamMembers from './TeamMembers';
import TeamSettings from './TeamSettings';
import TeamGroupSync from './TeamGroupSync';
import { NavModel, Team } from 'app/types';
import { loadTeam } from './state/actions';
import { getTeam } from './state/selectors';
import { NavModel, Team, TeamMember } from 'app/types';
import { loadTeam, loadTeamMembers } from './state/actions';
import { getTeam, getTeamMembers, isSignedInUserTeamAdmin } from './state/selectors';
import { getTeamLoadingNav } from './state/navModel';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId, getRouteParamsPage } from '../../core/selectors/location';
import { contextSrv, User } from 'app/core/services/context_srv';
export interface Props {
team: Team;
loadTeam: typeof loadTeam;
loadTeamMembers: typeof loadTeamMembers;
teamId: number;
pageName: string;
navModel: NavModel;
members?: TeamMember[];
editorsCanAdmin?: boolean;
signedInUser?: User;
}
interface State {
@@ -51,6 +56,7 @@ export class TeamPages extends PureComponent<Props, State> {
const { loadTeam, teamId } = this.props;
this.setState({ isLoading: true });
const team = await loadTeam(teamId);
await this.props.loadTeamMembers();
this.setState({ isLoading: false });
return team;
}
@@ -61,30 +67,56 @@ export class TeamPages extends PureComponent<Props, State> {
return _.includes(pages, currentPage) ? currentPage : pages[0];
}
renderPage() {
textsAreEqual = (text1: string, text2: string) => {
if (!text1 && !text2) {
return true;
}
if (!text1 || !text2) {
return false;
}
return text1.toLocaleLowerCase() === text2.toLocaleLowerCase();
};
hideTabsFromNonTeamAdmin = (navModel: NavModel, isSignedInUserTeamAdmin: boolean) => {
if (!isSignedInUserTeamAdmin && navModel.main && navModel.main.children) {
navModel.main.children
.filter(navItem => !this.textsAreEqual(navItem.text, PageTypes.Members))
.map(navItem => {
navItem.hideFromTabs = true;
});
}
return navModel;
};
renderPage(isSignedInUserTeamAdmin: boolean) {
const { isSyncEnabled } = this.state;
const { members } = this.props;
const currentPage = this.getCurrentPage();
switch (currentPage) {
case PageTypes.Members:
return <TeamMembers syncEnabled={isSyncEnabled} />;
return <TeamMembers syncEnabled={isSyncEnabled} members={members} />;
case PageTypes.Settings:
return <TeamSettings />;
return isSignedInUserTeamAdmin && <TeamSettings />;
case PageTypes.GroupSync:
return isSyncEnabled && <TeamGroupSync />;
return isSignedInUserTeamAdmin && isSyncEnabled && <TeamGroupSync />;
}
return null;
}
render() {
const { team, navModel } = this.props;
const { team, navModel, members, editorsCanAdmin, signedInUser } = this.props;
const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser });
return (
<Page navModel={navModel}>
<Page navModel={this.hideTabsFromNonTeamAdmin(navModel, isTeamAdmin)}>
<Page.Contents isLoading={this.state.isLoading}>
{team && Object.keys(team).length !== 0 && this.renderPage()}
{team && Object.keys(team).length !== 0 && this.renderPage(isTeamAdmin)}
</Page.Contents>
</Page>
);
@@ -95,17 +127,24 @@ function mapStateToProps(state) {
const teamId = getRouteParamsId(state.location);
const pageName = getRouteParamsPage(state.location) || 'members';
const teamLoadingNav = getTeamLoadingNav(pageName);
const navModel = getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav);
const team = getTeam(state.team, teamId);
const members = getTeamMembers(state.team);
return {
navModel: getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav),
navModel,
teamId: teamId,
pageName: pageName,
team: getTeam(state.team, teamId),
team,
members,
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
};
}
const mapDispatchToProps = {
loadTeam,
loadTeamMembers,
};
export default hot(module)(

View File

@@ -1,4 +1,4 @@
import { Team, TeamGroup, TeamMember } from 'app/types';
import { Team, TeamGroup, TeamMember, TeamPermissionLevel } from 'app/types';
export const getMultipleMockTeams = (numberOfTeams: number): Team[] => {
const teams: Team[] = [];
@@ -9,6 +9,7 @@ export const getMultipleMockTeams = (numberOfTeams: number): Team[] => {
avatarUrl: 'some/url/',
email: `test-${i}@test.com`,
memberCount: i,
permission: TeamPermissionLevel.Member,
});
}
@@ -22,10 +23,11 @@ export const getMockTeam = (): Team => {
avatarUrl: 'some/url/',
email: 'test@test.com',
memberCount: 1,
permission: TeamPermissionLevel.Member,
};
};
export const getMockTeamMembers = (amount: number): TeamMember[] => {
export const getMockTeamMembers = (amount: number, teamAdminId: number): TeamMember[] => {
const teamMembers: TeamMember[] = [];
for (let i = 1; i <= amount; i++) {
@@ -36,6 +38,7 @@ export const getMockTeamMembers = (amount: number): TeamMember[] => {
email: 'test@test.com',
login: `testUser-${i}`,
labels: ['label 1', 'label 2'],
permission: i === teamAdminId ? TeamPermissionLevel.Admin : TeamPermissionLevel.Member,
});
}
@@ -50,6 +53,7 @@ export const getMockTeamMember = (): TeamMember => {
email: 'test@test.com',
login: 'testUser',
labels: [],
permission: TeamPermissionLevel.Member,
};
};

View File

@@ -133,6 +133,7 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
@@ -183,6 +184,7 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
@@ -233,6 +235,7 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
@@ -283,6 +286,7 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
@@ -333,6 +337,259 @@ exports[`Render should render teams table 1`] = `
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
</tr>
</tbody>
</table>
</div>
</PageContents>
</Page>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on and signedin user is a viewer should disable the new team button 1`] = `
<Page
navModel={
Object {
"main": Object {
"text": "Configuration",
},
"node": Object {
"text": "Team List",
},
}
}
>
<PageContents
isLoading={false}
>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<ForwardRef
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search teams"
value=""
/>
</div>
<div
className="page-action-bar__spacer"
/>
<a
className="btn btn-primary disabled"
href="#"
>
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
disabled={true}
onConfirm={[Function]}
/>
</td>
</tr>
</tbody>
</table>
</div>
</PageContents>
</Page>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on and signedin user is not viewer should enable the new team button 1`] = `
<Page
navModel={
Object {
"main": Object {
"text": "Configuration",
},
"node": Object {
"text": "Team List",
},
}
}
>
<PageContents
isLoading={false}
>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<ForwardRef
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search teams"
value=""
/>
</div>
<div
className="page-action-bar__spacer"
/>
<a
className="btn btn-primary"
href="org/teams/new"
>
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
disabled={true}
onConfirm={[Function]}
/>
</td>

View File

@@ -0,0 +1,250 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render team members when sync enabled 1`] = `
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser
</td>
<td>
test@test.com
</td>
<Component
featureToggle={false}
>
<td
className="width-5 team-permissions"
>
<div
className="gf-form"
>
<span>
Member
</span>
</div>
</td>
</Component>
<td>
<TagBadge
count={0}
key="LDAP"
label="LDAP"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
disabled={true}
onConfirm={[Function]}
/>
</td>
</tr>
`;
exports[`Render when feature toggle editorsCanAdmin is turned off should not render permissions 1`] = `
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser
</td>
<td>
test@test.com
</td>
<Component
featureToggle={false}
>
<td
className="width-5 team-permissions"
>
<div
className="gf-form"
>
<Select
autoFocus={false}
backspaceRemovesValue={true}
className="gf-form-select-box__control--menu-right"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={false}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
Object {
"description": "Is team member",
"label": "Member",
"value": 0,
},
Object {
"description": "Can add/remove permissions, members and delete team.",
"label": "Admin",
"value": 4,
},
]
}
value={
Object {
"description": "Is team member",
"label": "Member",
"value": 0,
}
}
width={null}
/>
</div>
</td>
</Component>
<td
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
</tr>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is team admin 1`] = `
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser
</td>
<td>
test@test.com
</td>
<Component
featureToggle={true}
>
<td
className="width-5 team-permissions"
>
<div
className="gf-form"
>
<Select
autoFocus={false}
backspaceRemovesValue={true}
className="gf-form-select-box__control--menu-right"
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={false}
maxMenuHeight={300}
onChange={[Function]}
openMenuOnFocus={false}
options={
Array [
Object {
"description": "Is team member",
"label": "Member",
"value": 0,
},
Object {
"description": "Can add/remove permissions, members and delete team.",
"label": "Admin",
"value": 4,
},
]
}
value={
Object {
"description": "Is team member",
"label": "Member",
"value": 0,
}
}
width={null}
/>
</div>
</td>
</Component>
<td
className="text-right"
>
<DeleteButton
disabled={false}
onConfirm={[Function]}
/>
</td>
</tr>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on should render span and disable buttons if user is team member 1`] = `
<tr
key="1"
>
<td
className="width-4 text-center"
>
<img
className="filter-table__avatar"
src="some/url/"
/>
</td>
<td>
testUser
</td>
<td>
test@test.com
</td>
<Component
featureToggle={true}
>
<td
className="width-5 team-permissions"
>
<div
className="gf-form"
>
<span>
Member
</span>
</div>
</td>
</Component>
<td
className="text-right"
>
<DeleteButton
disabled={true}
onConfirm={[Function]}
/>
</td>
</tr>
`;

View File

@@ -69,6 +69,13 @@ exports[`Render should render component 1`] = `
<th>
Email
</th>
<Component
featureToggle={false}
>
<th>
Permission
</th>
</Component>
<th
style={
Object {
@@ -153,6 +160,13 @@ exports[`Render should render team members 1`] = `
<th>
Email
</th>
<Component
featureToggle={false}
>
<th>
Permission
</th>
</Component>
<th
style={
Object {
@@ -163,422 +177,106 @@ exports[`Render should render team members 1`] = `
</tr>
</thead>
<tbody>
<tr
<Connect(TeamMemberRow)
editorsCanAdmin={false}
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
onConfirm={[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
onConfirm={[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
onConfirm={[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
onConfirm={[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
onConfirm={[Function]}
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`;
exports[`Render should render team members when sync enabled 1`] = `
<div>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<ForwardRef
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search members"
value=""
/>
</div>
<div
className="page-action-bar__spacer"
/>
<button
className="btn btn-primary pull-right"
disabled={false}
onClick={[Function]}
>
Add 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="min-width-30"
onSelected={[Function]}
/>
</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 />
<th
style={
Object {
"width": "1%",
}
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-1",
"permission": 0,
"teamId": 1,
"userId": 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>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
<Connect(TeamMemberRow)
editorsCanAdmin={false}
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>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-2",
"permission": 0,
"teamId": 1,
"userId": 2,
}
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
<Connect(TeamMemberRow)
editorsCanAdmin={false}
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>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-3",
"permission": 0,
"teamId": 1,
"userId": 3,
}
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
<Connect(TeamMemberRow)
editorsCanAdmin={false}
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>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
<tr
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-4",
"permission": 0,
"teamId": 1,
"userId": 4,
}
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
<Connect(TeamMemberRow)
editorsCanAdmin={false}
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>
<TagBadge
count={0}
key="label 1"
label="label 1"
onClick={[Function]}
removeIcon={false}
/>
<TagBadge
count={0}
key="label 2"
label="label 2"
onClick={[Function]}
removeIcon={false}
/>
</td>
<td
className="text-right"
>
<DeleteButton
onConfirm={[Function]}
/>
</td>
</tr>
member={
Object {
"avatarUrl": "some/url/",
"email": "test@test.com",
"labels": Array [
"label 1",
"label 2",
],
"login": "testUser-5",
"permission": 4,
"teamId": 1,
"userId": 5,
}
}
signedInUserIsTeamAdmin={true}
syncEnabled={false}
/>
</tbody>
</table>
</div>

View File

@@ -30,6 +30,7 @@ exports[`Render should render member page if team not empty 1`] = `
isLoading={true}
>
<Connect(TeamMembers)
members={Array []}
syncEnabled={true}
/>
</PageContents>
@@ -47,3 +48,25 @@ exports[`Render should render settings and preferences page 1`] = `
</PageContents>
</Page>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on should not render settings page if user is team member 1`] = `
<Page
navModel={Object {}}
>
<PageContents
isLoading={true}
/>
</Page>
`;
exports[`Render when feature toggle editorsCanAdmin is turned on should render settings page if user is team admin 1`] = `
<Page
navModel={Object {}}
>
<PageContents
isLoading={true}
>
<Connect(TeamSettings) />
</PageContents>
</Page>
`;

View File

@@ -160,3 +160,12 @@ export function deleteTeam(id: number): ThunkResult<void> {
dispatch(loadTeams());
};
}
export function updateTeamMember(member: TeamMember): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().put(`/api/teams/${member.teamId}/members/${member.userId}`, {
permission: member.permission,
});
dispatch(loadTeamMembers());
};
}

View File

@@ -1,4 +1,4 @@
import { Team, NavModelItem, NavModel } from 'app/types';
import { Team, NavModelItem, NavModel, TeamPermissionLevel } from 'app/types';
import config from 'app/core/config';
export function buildNavModel(team: Team): NavModelItem {
@@ -47,6 +47,7 @@ export function getTeamLoadingNav(pageName: string): NavModel {
name: 'Loading',
email: 'loading',
memberCount: 0,
permission: TeamPermissionLevel.Member,
});
let node: NavModelItem;

View File

@@ -1,6 +1,7 @@
import { getTeam, getTeamMembers, getTeams } from './selectors';
import { getTeam, getTeamMembers, getTeams, isSignedInUserTeamAdmin, Config } from './selectors';
import { getMockTeam, getMockTeamMembers, getMultipleMockTeams } from '../__mocks__/teamMocks';
import { Team, TeamGroup, TeamsState, TeamState } from '../../../types';
import { Team, TeamGroup, TeamsState, TeamState, OrgRole } from '../../../types';
import { User } from 'app/core/services/context_srv';
describe('Teams selectors', () => {
describe('Get teams', () => {
@@ -40,7 +41,7 @@ describe('Team selectors', () => {
});
describe('Get members', () => {
const mockTeamMembers = getMockTeamMembers(5);
const mockTeamMembers = getMockTeamMembers(5, 5);
it('should return team members', () => {
const mockState: TeamState = {
@@ -55,3 +56,94 @@ describe('Team selectors', () => {
});
});
});
const signedInUserId = 1;
const setup = (configOverrides?: Partial<Config>) => {
const defaultConfig: Config = {
editorsCanAdmin: false,
members: getMockTeamMembers(5, 5),
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
};
return { ...defaultConfig, ...configOverrides };
};
describe('isSignedInUserTeamAdmin', () => {
describe('when feature toggle editorsCanAdmin is turned off', () => {
it('should return true', () => {
const config = setup();
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
it('should return true if signed in user is grafanaAdmin', () => {
const config = setup({
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: true,
orgRole: OrgRole.Viewer,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
it('should return true if signed in user is org admin', () => {
const config = setup({
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Admin,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
it('should return true if signed in user is team admin', () => {
const config = setup({
members: getMockTeamMembers(5, signedInUserId),
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
it('should return false if signed in user is not grafanaAdmin, org admin or team admin', () => {
const config = setup({
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(false);
});
});
});

View File

@@ -1,4 +1,5 @@
import { Team, TeamsState, TeamState } from 'app/types';
import { Team, TeamsState, TeamState, TeamMember, OrgRole, TeamPermissionLevel } from 'app/types';
import { User } from 'app/core/services/context_srv';
export const getSearchQuery = (state: TeamsState) => state.searchQuery;
export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery;
@@ -28,3 +29,32 @@ export const getTeamMembers = (state: TeamState) => {
return regex.test(member.login) || regex.test(member.email);
});
};
export interface Config {
members: TeamMember[];
editorsCanAdmin: boolean;
signedInUser: User;
}
export const isSignedInUserTeamAdmin = (config: Config): boolean => {
const { members, signedInUser, editorsCanAdmin } = config;
const userInMembers = members.find(m => m.userId === signedInUser.id);
const permission = userInMembers ? userInMembers.permission : TeamPermissionLevel.Member;
return isPermissionTeamAdmin({ permission, signedInUser, editorsCanAdmin });
};
export interface PermissionConfig {
permission: TeamPermissionLevel;
editorsCanAdmin: boolean;
signedInUser: User;
}
export const isPermissionTeamAdmin = (config: PermissionConfig): boolean => {
const { permission, signedInUser, editorsCanAdmin } = config;
const isAdmin = signedInUser.isGrafanaAdmin || signedInUser.orgRole === OrgRole.Admin;
const userIsTeamAdmin = permission === TeamPermissionLevel.Admin;
const isSignedInUserTeamAdmin = isAdmin || userIsTeamAdmin;
return isSignedInUserTeamAdmin || !editorsCanAdmin;
};