mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
merge master
This commit is contained in:
@@ -322,7 +322,7 @@ jobs:
|
||||
|
||||
deploy-enterprise-master:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.1
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
@@ -345,7 +345,7 @@ jobs:
|
||||
|
||||
deploy-enterprise-release:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.1
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
@@ -378,7 +378,7 @@ jobs:
|
||||
|
||||
deploy-master:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.1
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
@@ -409,7 +409,7 @@ jobs:
|
||||
|
||||
deploy-release:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.1
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -20,6 +20,16 @@
|
||||
* **Datasource**: Empty user/password was not updated when updating datasources [#15608](https://github.com/grafana/grafana/pull/15608), thx [@Maddin-619](https://github.com/Maddin-619)
|
||||
* **Heatmap**: legend shows wrong colors for small values [#14019](https://github.com/grafana/grafana/issues/14019)
|
||||
|
||||
# 6.0.2 (2019-03-19)
|
||||
|
||||
### Bug Fixes
|
||||
* **Alerting**: Fixed issue with AlertList panel links resulting in panel not found errors. [#15975](https://github.com/grafana/grafana/pull/15975), [@torkelo](https://github.com/torkelo)
|
||||
* **Dashboard**: Improved error handling when rendering dashboard panels. [#15970](https://github.com/grafana/grafana/pull/15970), [@torkelo](https://github.com/torkelo)
|
||||
* **LDAP**: Fix allow anonymous server bind for ldap search. [#15872](https://github.com/grafana/grafana/pull/15872), [@marefr](https://github.com/marefr)
|
||||
* **Discord**: Fix discord notifier so it doesn't crash when there are no image generated. [#15833](https://github.com/grafana/grafana/pull/15833), [@marefr](https://github.com/marefr)
|
||||
* **Panel Edit**: Prevent search in VizPicker from stealing focus. [#15802](https://github.com/grafana/grafana/pull/15802), [@peterholmberg](https://github.com/peterholmberg)
|
||||
* **Datasource admin**: Fixed url of back button in datasource edit page, when root_url configured. [#15759](https://github.com/grafana/grafana/pull/15759), [@dprokop](https://github.com/dprokop)
|
||||
|
||||
# 6.0.1 (2019-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -259,7 +259,7 @@ external_manage_info =
|
||||
viewers_can_edit = false
|
||||
|
||||
# Editors can administrate dashboard, folders and teams they create
|
||||
editors_can_own = false
|
||||
editors_can_admin = false
|
||||
|
||||
[auth]
|
||||
# Login cookie name
|
||||
|
||||
@@ -239,7 +239,7 @@ log_queries =
|
||||
;viewers_can_edit = false
|
||||
|
||||
# Editors can administrate dashboard, folders and teams they create
|
||||
;editors_can_own = false
|
||||
;editors_can_admin = false
|
||||
|
||||
[auth]
|
||||
# Login cookie name
|
||||
|
||||
@@ -354,6 +354,11 @@ options are `Admin` and `Editor`. e.g. :
|
||||
Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
|
||||
Defaults to `false`.
|
||||
|
||||
### editors_can_admin
|
||||
|
||||
Editors can administrate dashboards, folders and teams they create.
|
||||
Defaults to `false`.
|
||||
|
||||
### login_hint
|
||||
|
||||
Text used as placeholder text on login page for login/username input.
|
||||
|
||||
@@ -28,6 +28,9 @@ Can do everything scoped to the organization. For example:
|
||||
- Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards.
|
||||
- **Cannot** create or edit data sources nor invite new users.
|
||||
|
||||
This role can be tweaked via Grafana server setting [editors_can_admin]({{< relref "installation/configuration.md#editors_can_admin" >}}). If you set this to true users
|
||||
with **Editor** can also administrate dashboards, folders and teams they create. Useful for enabling self organizing teams.
|
||||
|
||||
## Viewer Role
|
||||
|
||||
- View any dashboard. This can be disabled on specific folders and dashboards.
|
||||
|
||||
@@ -110,7 +110,7 @@ Formats single & multi valued variables for use in URL parameters.
|
||||
|
||||
```bash
|
||||
servers = ['foo()bar BAZ', 'test2']
|
||||
String to interpolate: '${servers:lucene}'
|
||||
String to interpolate: '${servers:percentencode}'
|
||||
Interpolation result: 'foo%28%29bar%20BAZ%2Ctest2'
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"stable": "6.0.1",
|
||||
"testing": "6.0.1"
|
||||
"stable": "6.0.2",
|
||||
"testing": "6.0.2"
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -27,8 +27,8 @@
|
||||
"@types/jest": "^23.3.2",
|
||||
"@types/jquery": "^1.10.35",
|
||||
"@types/node": "^8.0.31",
|
||||
"@types/react": "^16.7.6",
|
||||
"@types/react-dom": "^16.0.9",
|
||||
"@types/react": "^16.8.8",
|
||||
"@types/react-dom": "^16.8.2",
|
||||
"@types/react-grid-layout": "^0.16.6",
|
||||
"@types/react-select": "^2.0.4",
|
||||
"@types/react-transition-group": "^2.0.15",
|
||||
@@ -191,8 +191,8 @@
|
||||
"prismjs": "^1.6.0",
|
||||
"prop-types": "^15.6.2",
|
||||
"rc-cascader": "^0.14.0",
|
||||
"react": "^16.6.3",
|
||||
"react-dom": "^16.6.3",
|
||||
"react": "^16.8.4",
|
||||
"react-dom": "^16.8.4",
|
||||
"react-grid-layout": "0.16.6",
|
||||
"react-highlight-words": "0.11.0",
|
||||
"react-popper": "^1.3.0",
|
||||
@@ -219,7 +219,7 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"caniuse-db": "1.0.30000772",
|
||||
"**/@types/react": "16.7.6"
|
||||
"**/@types/react": "16.8.8"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
"lodash": "^4.17.10",
|
||||
"moment": "^2.22.2",
|
||||
"papaparse": "^4.6.3",
|
||||
"react": "^16.6.3",
|
||||
"react": "^16.8.4",
|
||||
"react-color": "^2.17.0",
|
||||
"react-custom-scrollbars": "^4.2.1",
|
||||
"react-dom": "^16.6.3",
|
||||
"react-dom": "^16.8.4",
|
||||
"react-highlight-words": "0.11.0",
|
||||
"react-popper": "^1.3.0",
|
||||
"react-transition-group": "^2.2.1",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@types/lodash": "^4.14.119",
|
||||
"@types/node": "^10.12.18",
|
||||
"@types/papaparse": "^4.5.9",
|
||||
"@types/react": "^16.7.6",
|
||||
"@types/react": "^16.8.8",
|
||||
"@types/react-custom-scrollbars": "^4.0.5",
|
||||
"@types/react-test-renderer": "^16.0.3",
|
||||
"@types/react-transition-group": "^2.0.15",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { number, text } from '@storybook/addon-knobs';
|
||||
import { number, text, boolean } from '@storybook/addon-knobs';
|
||||
import { BarGauge } from './BarGauge';
|
||||
import { VizOrientation } from '../../types';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
|
||||
|
||||
@@ -15,6 +16,8 @@ const getKnobs = () => {
|
||||
threshold2Color: text('threshold2Color', 'red'),
|
||||
unit: text('unit', 'ms'),
|
||||
decimals: number('decimals', 1),
|
||||
horizontal: boolean('horizontal', false),
|
||||
lcd: boolean('lcd', false),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,7 +25,7 @@ const BarGaugeStories = storiesOf('UI/BarGauge/BarGauge', module);
|
||||
|
||||
BarGaugeStories.addDecorator(withCenteredStory);
|
||||
|
||||
BarGaugeStories.add('Vertical, with basic thresholds', () => {
|
||||
BarGaugeStories.add('Simple with basic thresholds', () => {
|
||||
const {
|
||||
value,
|
||||
minValue,
|
||||
@@ -33,11 +36,13 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
|
||||
threshold2Value,
|
||||
unit,
|
||||
decimals,
|
||||
horizontal,
|
||||
lcd,
|
||||
} = getKnobs();
|
||||
|
||||
return renderComponentWithTheme(BarGauge, {
|
||||
width: 200,
|
||||
height: 400,
|
||||
width: 700,
|
||||
height: 700,
|
||||
value: value,
|
||||
minValue: minValue,
|
||||
maxValue: maxValue,
|
||||
@@ -45,6 +50,8 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
|
||||
prefix: '',
|
||||
postfix: '',
|
||||
decimals: decimals,
|
||||
orientation: horizontal ? VizOrientation.Horizontal : VizOrientation.Vertical,
|
||||
displayMode: lcd ? 'lcd' : 'simple',
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: 'green' },
|
||||
{ index: 1, value: threshold1Value, color: threshold1Color },
|
||||
|
||||
@@ -12,6 +12,7 @@ const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
maxValue: 100,
|
||||
minValue: 0,
|
||||
displayMode: 'basic',
|
||||
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
|
||||
height: 300,
|
||||
width: 300,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Library
|
||||
import React, { PureComponent, CSSProperties } from 'react';
|
||||
import React, { PureComponent, CSSProperties, ReactNode } from 'react';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
// Utils
|
||||
@@ -18,11 +18,9 @@ export interface Props extends Themeable {
|
||||
maxValue: number;
|
||||
minValue: number;
|
||||
orientation: VizOrientation;
|
||||
displayMode: 'basic' | 'lcd' | 'gradient';
|
||||
}
|
||||
|
||||
/*
|
||||
* This visualization is still in POC state, needed more tests & better structure
|
||||
*/
|
||||
export class BarGauge extends PureComponent<Props> {
|
||||
static defaultProps: Partial<Props> = {
|
||||
maxValue: 100,
|
||||
@@ -31,10 +29,22 @@ export class BarGauge extends PureComponent<Props> {
|
||||
text: '100',
|
||||
numeric: 100,
|
||||
},
|
||||
displayMode: 'lcd',
|
||||
orientation: VizOrientation.Horizontal,
|
||||
thresholds: [],
|
||||
};
|
||||
|
||||
render() {
|
||||
switch (this.props.displayMode) {
|
||||
case 'lcd':
|
||||
return this.renderRetroBars();
|
||||
case 'basic':
|
||||
case 'gradient':
|
||||
default:
|
||||
return this.renderBasicAndGradientBars();
|
||||
}
|
||||
}
|
||||
|
||||
getValueColors(): BarColors {
|
||||
const { thresholds, theme, value } = this.props;
|
||||
|
||||
@@ -46,41 +56,19 @@ export class BarGauge extends PureComponent<Props> {
|
||||
return {
|
||||
value: color,
|
||||
border: color,
|
||||
bar: tinycolor(color)
|
||||
.setAlpha(0.3)
|
||||
background: tinycolor(color)
|
||||
.setAlpha(0.15)
|
||||
.toRgbString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: getColorFromHexRgbOrName('gray', theme.type),
|
||||
bar: getColorFromHexRgbOrName('gray', theme.type),
|
||||
background: getColorFromHexRgbOrName('gray', theme.type),
|
||||
border: getColorFromHexRgbOrName('gray', theme.type),
|
||||
};
|
||||
}
|
||||
|
||||
getCellColor(positionValue: TimeSeriesValue): string {
|
||||
const { thresholds, theme, value } = this.props;
|
||||
const activeThreshold = getThresholdForValue(thresholds, positionValue);
|
||||
|
||||
if (activeThreshold !== null) {
|
||||
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
|
||||
|
||||
// if we are past real value the cell is not "on"
|
||||
if (value === null || (positionValue !== null && positionValue > value.numeric)) {
|
||||
return tinycolor(color)
|
||||
.setAlpha(0.15)
|
||||
.toRgbString();
|
||||
} else {
|
||||
return tinycolor(color)
|
||||
.setAlpha(0.7)
|
||||
.toRgbString();
|
||||
}
|
||||
}
|
||||
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
getValueStyles(value: string, color: string, width: number): CSSProperties {
|
||||
const guess = width / (value.length * 1.1);
|
||||
const fontSize = Math.min(Math.max(guess, 14), 40);
|
||||
@@ -91,107 +79,205 @@ export class BarGauge extends PureComponent<Props> {
|
||||
};
|
||||
}
|
||||
|
||||
renderVerticalBar(valueFormatted: string, valuePercent: number) {
|
||||
/*
|
||||
* Return width or height depending on viz orientation
|
||||
* */
|
||||
get size() {
|
||||
const { height, width } = this.props;
|
||||
return this.isVertical ? height : width;
|
||||
}
|
||||
|
||||
const maxHeight = height * BAR_SIZE_RATIO;
|
||||
const barHeight = Math.max(valuePercent * maxHeight, 0);
|
||||
get isVertical() {
|
||||
return this.props.orientation === VizOrientation.Vertical;
|
||||
}
|
||||
|
||||
getBarGradient(maxSize: number): string {
|
||||
const { minValue, maxValue, thresholds, value } = this.props;
|
||||
const cssDirection = this.isVertical ? '0deg' : '90deg';
|
||||
|
||||
let gradient = '';
|
||||
let lastpos = 0;
|
||||
|
||||
for (let i = 0; i < thresholds.length; i++) {
|
||||
const threshold = thresholds[i];
|
||||
const color = getColorFromHexRgbOrName(threshold.color);
|
||||
const valuePercent = Math.min(threshold.value / (maxValue - minValue), 1);
|
||||
const pos = valuePercent * maxSize;
|
||||
const offset = Math.round(pos - (pos - lastpos) / 2);
|
||||
|
||||
if (gradient === '') {
|
||||
gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
|
||||
} else if (value.numeric < threshold.value) {
|
||||
break;
|
||||
} else {
|
||||
lastpos = pos;
|
||||
gradient += ` ${offset}px, ${color}`;
|
||||
}
|
||||
}
|
||||
|
||||
return gradient + ')';
|
||||
}
|
||||
|
||||
renderBasicAndGradientBars(): ReactNode {
|
||||
const { height, width, displayMode, maxValue, minValue, value } = this.props;
|
||||
|
||||
const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1);
|
||||
const maxSize = this.size * BAR_SIZE_RATIO;
|
||||
const barSize = Math.max(valuePercent * maxSize, 0);
|
||||
const colors = this.getValueColors();
|
||||
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width);
|
||||
const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
|
||||
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
|
||||
const isBasic = displayMode === 'basic';
|
||||
|
||||
const containerStyles: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
};
|
||||
|
||||
const barStyles: CSSProperties = {
|
||||
height: `${barHeight}px`,
|
||||
width: `${width}px`,
|
||||
backgroundColor: colors.bar,
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
borderRadius: '3px',
|
||||
};
|
||||
|
||||
if (this.isVertical) {
|
||||
// Custom styles for vertical orientation
|
||||
containerStyles.flexDirection = 'column';
|
||||
containerStyles.justifyContent = 'flex-end';
|
||||
barStyles.transition = 'height 1s';
|
||||
barStyles.height = `${barSize}px`;
|
||||
barStyles.width = `${width}px`;
|
||||
if (isBasic) {
|
||||
// Basic styles
|
||||
barStyles.background = `${colors.background}`;
|
||||
barStyles.border = `1px solid ${colors.border}`;
|
||||
barStyles.boxShadow = `0 0 4px ${colors.border}`;
|
||||
} else {
|
||||
// Gradient styles
|
||||
barStyles.background = this.getBarGradient(maxSize);
|
||||
}
|
||||
} else {
|
||||
// Custom styles for horizontal orientation
|
||||
containerStyles.flexDirection = 'row-reverse';
|
||||
containerStyles.justifyContent = 'flex-end';
|
||||
containerStyles.alignItems = 'center';
|
||||
barStyles.transition = 'width 1s';
|
||||
barStyles.height = `${height}px`;
|
||||
barStyles.width = `${barSize}px`;
|
||||
barStyles.marginRight = '10px';
|
||||
|
||||
if (isBasic) {
|
||||
// Basic styles
|
||||
barStyles.background = `${colors.background}`;
|
||||
barStyles.border = `1px solid ${colors.border}`;
|
||||
barStyles.boxShadow = `0 0 4px ${colors.border}`;
|
||||
} else {
|
||||
// Gradient styles
|
||||
barStyles.background = this.getBarGradient(maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyles}>
|
||||
<div className="bar-gauge__value" style={valueStyles}>
|
||||
{valueFormatted}
|
||||
{value.text}
|
||||
</div>
|
||||
<div style={barStyles} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderHorizontalBar(valueFormatted: string, valuePercent: number) {
|
||||
const { height, width } = this.props;
|
||||
getCellColor(positionValue: TimeSeriesValue): CellColors {
|
||||
const { thresholds, theme, value } = this.props;
|
||||
const activeThreshold = getThresholdForValue(thresholds, positionValue);
|
||||
|
||||
const maxWidth = width * BAR_SIZE_RATIO;
|
||||
const barWidth = Math.max(valuePercent * maxWidth, 0);
|
||||
const colors = this.getValueColors();
|
||||
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
|
||||
if (activeThreshold !== null) {
|
||||
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
|
||||
|
||||
valueStyles.marginLeft = '8px';
|
||||
|
||||
const containerStyles: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
// if we are past real value the cell is not "on"
|
||||
if (value === null || (positionValue !== null && positionValue > value.numeric)) {
|
||||
return {
|
||||
background: tinycolor(color)
|
||||
.setAlpha(0.15)
|
||||
.toRgbString(),
|
||||
border: 'transparent',
|
||||
isLit: false,
|
||||
};
|
||||
|
||||
const barStyles = {
|
||||
height: `${height}px`,
|
||||
width: `${barWidth}px`,
|
||||
backgroundColor: colors.bar,
|
||||
borderRight: `1px solid ${colors.border}`,
|
||||
} else {
|
||||
return {
|
||||
background: tinycolor(color)
|
||||
.setAlpha(0.85)
|
||||
.toRgbString(),
|
||||
backgroundShade: tinycolor(color)
|
||||
.setAlpha(0.55)
|
||||
.toRgbString(),
|
||||
border: tinycolor(color)
|
||||
.setAlpha(0.9)
|
||||
.toRgbString(),
|
||||
isLit: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyles}>
|
||||
<div style={barStyles} />
|
||||
<div className="bar-gauge__value" style={valueStyles}>
|
||||
{valueFormatted}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderHorizontalLCD(valueFormatted: string, valuePercent: number) {
|
||||
const { height, width, maxValue, minValue } = this.props;
|
||||
return {
|
||||
background: 'gray',
|
||||
border: 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
renderRetroBars(): ReactNode {
|
||||
const { height, width, maxValue, minValue, value } = this.props;
|
||||
|
||||
const valueRange = maxValue - minValue;
|
||||
const maxWidth = width * BAR_SIZE_RATIO;
|
||||
const cellSpacing = 4;
|
||||
const cellCount = 30;
|
||||
const cellWidth = (maxWidth - cellSpacing * cellCount) / cellCount;
|
||||
const maxSize = this.size * BAR_SIZE_RATIO;
|
||||
const cellSpacing = 5;
|
||||
const cellCount = maxSize / 20;
|
||||
const cellSize = (maxSize - cellSpacing * cellCount) / cellCount;
|
||||
const colors = this.getValueColors();
|
||||
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
|
||||
valueStyles.marginLeft = '8px';
|
||||
const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
|
||||
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
|
||||
|
||||
const containerStyles: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
if (this.isVertical) {
|
||||
containerStyles.flexDirection = 'column-reverse';
|
||||
containerStyles.alignItems = 'center';
|
||||
valueStyles.marginBottom = '20px';
|
||||
} else {
|
||||
containerStyles.flexDirection = 'row';
|
||||
containerStyles.alignItems = 'center';
|
||||
valueStyles.marginLeft = '20px';
|
||||
}
|
||||
|
||||
const cells: JSX.Element[] = [];
|
||||
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const currentValue = (valueRange / cellCount) * i;
|
||||
const cellColor = this.getCellColor(currentValue);
|
||||
const cellStyles: CSSProperties = {
|
||||
width: `${cellWidth}px`,
|
||||
backgroundColor: cellColor,
|
||||
marginRight: '4px',
|
||||
height: `${height}px`,
|
||||
borderRadius: '2px',
|
||||
};
|
||||
|
||||
if (cellColor.isLit) {
|
||||
cellStyles.boxShadow = `0 0 4px ${cellColor.border}`;
|
||||
cellStyles.backgroundImage = `radial-gradient(${cellColor.background} 10%, ${cellColor.backgroundShade})`;
|
||||
} else {
|
||||
cellStyles.backgroundColor = cellColor.background;
|
||||
}
|
||||
|
||||
if (this.isVertical) {
|
||||
cellStyles.height = `${cellSize}px`;
|
||||
cellStyles.width = `${width}px`;
|
||||
cellStyles.marginTop = `${cellSpacing}px`;
|
||||
} else {
|
||||
cellStyles.width = `${cellSize}px`;
|
||||
cellStyles.height = `${height}px`;
|
||||
cellStyles.marginRight = `${cellSpacing}px`;
|
||||
}
|
||||
|
||||
cells.push(<div style={cellStyles} />);
|
||||
}
|
||||
|
||||
@@ -199,26 +285,22 @@ export class BarGauge extends PureComponent<Props> {
|
||||
<div style={containerStyles}>
|
||||
{cells}
|
||||
<div className="bar-gauge__value" style={valueStyles}>
|
||||
{valueFormatted}
|
||||
{value.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { maxValue, minValue, orientation, value } = this.props;
|
||||
|
||||
const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1);
|
||||
const vertical = orientation === 'vertical';
|
||||
|
||||
return vertical
|
||||
? this.renderVerticalBar(value.text, valuePercent)
|
||||
: this.renderHorizontalLCD(value.text, valuePercent);
|
||||
}
|
||||
}
|
||||
|
||||
interface BarColors {
|
||||
value: string;
|
||||
bar: string;
|
||||
background: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
interface CellColors {
|
||||
background: string;
|
||||
backgroundShade?: string;
|
||||
border: string;
|
||||
isLit?: boolean;
|
||||
}
|
||||
|
||||
@@ -6,353 +6,37 @@ exports[`Render BarGauge with basic options should render 1`] = `
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"display": "flex",
|
||||
"flexDirection": "row",
|
||||
"flexDirection": "row-reverse",
|
||||
"height": "300px",
|
||||
"justifyContent": "flex-end",
|
||||
"width": "300px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="bar-gauge__value"
|
||||
style={
|
||||
Object {
|
||||
"color": "#7EB26D",
|
||||
"fontSize": "27.272727272727263px",
|
||||
"marginLeft": "8px",
|
||||
"fontSize": "27.27272727272727px",
|
||||
}
|
||||
}
|
||||
>
|
||||
25
|
||||
</div>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"background": "rgba(126, 178, 109, 0.15)",
|
||||
"border": "1px solid #7EB26D",
|
||||
"borderRadius": "3px",
|
||||
"boxShadow": "0 0 4px #7EB26D",
|
||||
"height": "300px",
|
||||
"marginRight": "10px",
|
||||
"transition": "width 1s",
|
||||
"width": "60px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { PureComponent, SyntheticEvent } from 'react';
|
||||
|
||||
interface Props {
|
||||
onConfirm(): void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -33,25 +34,22 @@ export class DeleteButton extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onConfirm } = this.props;
|
||||
let showConfirm;
|
||||
let showDeleteButton;
|
||||
|
||||
if (this.state.showConfirm) {
|
||||
showConfirm = 'show';
|
||||
showDeleteButton = 'hide';
|
||||
} else {
|
||||
showConfirm = 'hide';
|
||||
showDeleteButton = 'show';
|
||||
}
|
||||
const { onConfirm, disabled } = this.props;
|
||||
const showConfirmClass = this.state.showConfirm ? 'show' : 'hide';
|
||||
const showDeleteButtonClass = this.state.showConfirm ? 'hide' : 'show';
|
||||
const disabledClass = disabled ? 'disabled btn-inverse' : '';
|
||||
const onClick = disabled ? () => {} : this.onClickDelete;
|
||||
|
||||
return (
|
||||
<span className="delete-button-container">
|
||||
<a className={'delete-button ' + showDeleteButton + ' btn btn-danger btn-small'} onClick={this.onClickDelete}>
|
||||
<a
|
||||
className={`delete-button ${showDeleteButtonClass} btn btn-danger btn-small ${disabledClass}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
<span className="confirm-delete-container">
|
||||
<span className={'confirm-delete ' + showConfirm}>
|
||||
<span className={`confirm-delete ${showConfirmClass}`}>
|
||||
<a className="btn btn-small" onClick={this.onClickCancel}>
|
||||
Cancel
|
||||
</a>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Input, EventsWithValidation } from './Input';
|
||||
import { ValidationEvents } from 'app/types';
|
||||
import { Input } from './Input';
|
||||
import { EventsWithValidation } from '../../utils';
|
||||
import { ValidationEvents } from '../../types';
|
||||
|
||||
const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
|
||||
const testBlurValidation: ValidationEvents = {
|
||||
[EventsWithValidation.onBlur]: [
|
||||
{
|
||||
rule: (value: string) => {
|
||||
if (!value || value.length < 3) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return !value || value.length < 3;
|
||||
},
|
||||
errorMessage: TEST_ERROR_MESSAGE,
|
||||
},
|
||||
@@ -1,26 +1,13 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import React, { PureComponent, ChangeEvent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { ValidationEvents, ValidationRule } from 'app/types';
|
||||
import { validate, hasValidationEvent } from 'app/core/utils/validate';
|
||||
import { validate, EventsWithValidation, hasValidationEvent } from '../../utils';
|
||||
import { ValidationEvents, ValidationRule } from '../../types';
|
||||
|
||||
export enum InputStatus {
|
||||
Invalid = 'invalid',
|
||||
Valid = 'valid',
|
||||
}
|
||||
|
||||
export enum InputTypes {
|
||||
Text = 'text',
|
||||
Number = 'number',
|
||||
Password = 'password',
|
||||
Email = 'email',
|
||||
}
|
||||
|
||||
export enum EventsWithValidation {
|
||||
onBlur = 'onBlur',
|
||||
onFocus = 'onFocus',
|
||||
onChange = 'onChange',
|
||||
}
|
||||
|
||||
interface Props extends React.HTMLProps<HTMLInputElement> {
|
||||
validationEvents?: ValidationEvents;
|
||||
hideErrorMessage?: boolean;
|
||||
@@ -28,7 +15,7 @@ interface Props extends React.HTMLProps<HTMLInputElement> {
|
||||
// Override event props and append status as argument
|
||||
onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
|
||||
onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
|
||||
onChange?: (event: React.FormEvent<HTMLInputElement>, status?: InputStatus) => void;
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>, status?: InputStatus) => void;
|
||||
}
|
||||
|
||||
export class Input extends PureComponent<Props> {
|
||||
@@ -49,24 +36,24 @@ export class Input extends PureComponent<Props> {
|
||||
}
|
||||
|
||||
validatorAsync = (validationRules: ValidationRule[]) => {
|
||||
return evt => {
|
||||
return (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const errors = validate(evt.target.value, validationRules);
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
error: errors ? errors[0] : null,
|
||||
};
|
||||
return { ...prevState, error: errors ? errors[0] : null };
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => {
|
||||
populateEventPropsWithStatus = (restProps: any, validationEvents: ValidationEvents | undefined) => {
|
||||
const inputElementProps = { ...restProps };
|
||||
Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => {
|
||||
if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) {
|
||||
inputElementProps[eventName] = async evt => {
|
||||
if (!validationEvents) {
|
||||
return inputElementProps;
|
||||
}
|
||||
Object.keys(EventsWithValidation).forEach(eventName => {
|
||||
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents) || restProps[eventName]) {
|
||||
inputElementProps[eventName] = async (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
|
||||
if (hasValidationEvent(eventName, validationEvents)) {
|
||||
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents)) {
|
||||
await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
|
||||
}
|
||||
if (restProps[eventName]) {
|
||||
@@ -25,6 +25,7 @@ const model: OptionProps<any> = {
|
||||
key: '',
|
||||
onClick: jest.fn(),
|
||||
onMouseOver: jest.fn(),
|
||||
onMouseMove: jest.fn(),
|
||||
tabIndex: 1,
|
||||
},
|
||||
label: 'Option label',
|
||||
|
||||
@@ -4,6 +4,7 @@ exports[`SelectOption renders correctly 1`] = `
|
||||
<div
|
||||
id=""
|
||||
onClick={[MockFunction]}
|
||||
onMouseMove={[MockFunction]}
|
||||
onMouseOver={[MockFunction]}
|
||||
tabIndex={1}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { ThresholdsEditor } from './ThresholdsEditor';
|
||||
|
||||
const ThresholdsEditorStories = storiesOf('UI/ThresholdsEditor', module);
|
||||
const thresholds = [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 50, color: 'red' }];
|
||||
|
||||
ThresholdsEditorStories.add('default', () => {
|
||||
return <ThresholdsEditor thresholds={[]} onChange={action('Thresholds changed')} />;
|
||||
});
|
||||
|
||||
ThresholdsEditorStories.add('with thresholds', () => {
|
||||
return <ThresholdsEditor thresholds={thresholds} onChange={action('Thresholds changed')} />;
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { ThresholdsEditor, Props } from './ThresholdsEditor';
|
||||
import { colors } from '../../utils';
|
||||
|
||||
const setup = (propOverrides?: Partial<Props>) => {
|
||||
const props: Props = {
|
||||
@@ -31,7 +32,7 @@ describe('Initialization', () => {
|
||||
it('should add a base threshold if missing', () => {
|
||||
const { instance } = setup();
|
||||
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +42,7 @@ describe('Add threshold', () => {
|
||||
|
||||
instance.onAddThreshold(0);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
|
||||
});
|
||||
|
||||
it('should add threshold', () => {
|
||||
@@ -50,41 +51,41 @@ describe('Add threshold', () => {
|
||||
instance.onAddThreshold(1);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add another threshold above a first', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
|
||||
thresholds: [{ index: 0, value: -Infinity, color: colors[0] }, { index: 1, value: 50, color: colors[2] }],
|
||||
});
|
||||
|
||||
instance.onAddThreshold(2);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
{ index: 2, value: 75, color: colors[3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add another threshold between first and second index', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
{ index: 2, value: 75, color: colors[3] },
|
||||
],
|
||||
});
|
||||
|
||||
instance.onAddThreshold(2);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 62.5, color: '#EF843C' },
|
||||
{ index: 3, value: 75, color: '#6ED0E0' },
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
{ index: 2, value: 62.5, color: colors[4] },
|
||||
{ index: 3, value: 75, color: colors[3] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -161,7 +162,7 @@ describe('change threshold value', () => {
|
||||
});
|
||||
|
||||
describe('on blur threshold value', () => {
|
||||
it.only('should resort rows and update indexes', () => {
|
||||
it('should resort rows and update indexes', () => {
|
||||
const { instance } = setup();
|
||||
const thresholds = [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Threshold } from '../../types';
|
||||
import { ColorPicker } from '..';
|
||||
import { PanelOptionsGroup } from '..';
|
||||
import { colors } from '../../utils';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
||||
import { ThemeContext } from '../../themes';
|
||||
import { getColorFromHexRgbOrName } from '../../utils';
|
||||
|
||||
export interface Props {
|
||||
thresholds: Threshold[];
|
||||
@@ -166,7 +166,11 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
<div className="thresholds-row-input-inner-color">
|
||||
{threshold.color && (
|
||||
<div className="thresholds-row-input-inner-color-colorpicker">
|
||||
<ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
|
||||
<ColorPicker
|
||||
color={threshold.color}
|
||||
onChange={color => this.onChangeThresholdColor(threshold, color)}
|
||||
enableNamedColors={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,450 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render with base threshold 1`] = `
|
||||
<ContextConsumer>
|
||||
<Component />
|
||||
</ContextConsumer>
|
||||
<ThresholdsEditor
|
||||
onChange={
|
||||
[MockFunction] {
|
||||
"calls": Array [
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"color": "#7EB26D",
|
||||
"index": 0,
|
||||
"value": -Infinity,
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
Object {
|
||||
"isThrow": false,
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
thresholds={Array []}
|
||||
>
|
||||
<Component
|
||||
title="Thresholds"
|
||||
>
|
||||
<div
|
||||
className="panel-options-group"
|
||||
>
|
||||
<div
|
||||
className="panel-options-group__header"
|
||||
>
|
||||
<span
|
||||
className="panel-options-group__title"
|
||||
>
|
||||
Thresholds
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="panel-options-group__body"
|
||||
>
|
||||
<div
|
||||
className="thresholds"
|
||||
>
|
||||
<div
|
||||
className="thresholds-row"
|
||||
key="0-0"
|
||||
>
|
||||
<div
|
||||
className="thresholds-row-add-button"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-plus"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="thresholds-row-color-indicator"
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "#7EB26D",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="thresholds-row-input"
|
||||
>
|
||||
<div
|
||||
className="thresholds-row-input-inner"
|
||||
>
|
||||
<span
|
||||
className="thresholds-row-input-inner-arrow"
|
||||
/>
|
||||
<div
|
||||
className="thresholds-row-input-inner-color"
|
||||
>
|
||||
<div
|
||||
className="thresholds-row-input-inner-color-colorpicker"
|
||||
>
|
||||
<WithTheme(ColorPicker)
|
||||
color="#7EB26D"
|
||||
enableNamedColors={true}
|
||||
onChange={[Function]}
|
||||
>
|
||||
<ColorPicker
|
||||
color="#7EB26D"
|
||||
enableNamedColors={true}
|
||||
onChange={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"background": Object {
|
||||
"dropdown": "#1f1f20",
|
||||
"scrollbar": "#343436",
|
||||
"scrollbar2": "#343436",
|
||||
},
|
||||
"border": Object {
|
||||
"radius": Object {
|
||||
"lg": "5px",
|
||||
"md": "3px",
|
||||
"sm": "2px",
|
||||
},
|
||||
"width": Object {
|
||||
"sm": "1px",
|
||||
},
|
||||
},
|
||||
"breakpoints": Object {
|
||||
"lg": "992px",
|
||||
"md": "768px",
|
||||
"sm": "544px",
|
||||
"xl": "1200px",
|
||||
"xs": "0",
|
||||
},
|
||||
"colors": Object {
|
||||
"black": "#000000",
|
||||
"blue": "#33b5e5",
|
||||
"blueBase": "#3274d9",
|
||||
"blueFaint": "#041126",
|
||||
"blueLight": "#5794f2",
|
||||
"blueShade": "#1f60c4",
|
||||
"body": "#d8d9da",
|
||||
"bodyBg": "#161719",
|
||||
"brandDanger": "#e02f44",
|
||||
"brandPrimary": "#eb7b18",
|
||||
"brandSuccess": "#299c46",
|
||||
"brandWarning": "#eb7b18",
|
||||
"critical": "#e02f44",
|
||||
"dark1": "#141414",
|
||||
"dark10": "#424345",
|
||||
"dark2": "#161719",
|
||||
"dark3": "#1f1f20",
|
||||
"dark4": "#212124",
|
||||
"dark5": "#222426",
|
||||
"dark6": "#262628",
|
||||
"dark7": "#292a2d",
|
||||
"dark8": "#2f2f32",
|
||||
"dark9": "#343436",
|
||||
"gray1": "#555555",
|
||||
"gray2": "#8e8e8e",
|
||||
"gray3": "#b3b3b3",
|
||||
"gray4": "#d8d9da",
|
||||
"gray5": "#ececec",
|
||||
"gray6": "#f4f5f8",
|
||||
"gray7": "#fbfbfb",
|
||||
"grayBlue": "#212327",
|
||||
"greenBase": "#299c46",
|
||||
"greenShade": "#23843b",
|
||||
"headingColor": "#e3e3e3",
|
||||
"inputBlack": "#09090b",
|
||||
"link": "#e3e3e3",
|
||||
"linkDisabled": "#e3e3e3",
|
||||
"linkExternal": "#33b5e5",
|
||||
"linkHover": "#ffffff",
|
||||
"online": "#299c46",
|
||||
"orange": "#eb7b18",
|
||||
"pageBg": "#161719",
|
||||
"purple": "#9933cc",
|
||||
"queryGreen": "#74e680",
|
||||
"queryKeyword": "#66d9ef",
|
||||
"queryOrange": "#eb7b18",
|
||||
"queryPurple": "#fe85fc",
|
||||
"queryRed": "#e02f44",
|
||||
"red": "#d44a3a",
|
||||
"redBase": "#e02f44",
|
||||
"redShade": "#c4162a",
|
||||
"text": "#d8d9da",
|
||||
"textEmphasis": "#ececec",
|
||||
"textFaint": "#222426",
|
||||
"textStrong": "#ffffff",
|
||||
"textWeak": "#8e8e8e",
|
||||
"variable": "#32d1df",
|
||||
"warn": "#f79520",
|
||||
"white": "#ffffff",
|
||||
"yellow": "#ecbb13",
|
||||
},
|
||||
"name": "Grafana Dark",
|
||||
"panelPadding": Object {
|
||||
"horizontal": 10,
|
||||
"vertical": 5,
|
||||
},
|
||||
"spacing": Object {
|
||||
"d": "14px",
|
||||
"gutter": "30px",
|
||||
"lg": "24px",
|
||||
"md": "16px",
|
||||
"sm": "8px",
|
||||
"xl": "32px",
|
||||
"xs": "4px",
|
||||
"xxs": "2px",
|
||||
},
|
||||
"type": "dark",
|
||||
"typography": Object {
|
||||
"fontFamily": Object {
|
||||
"monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
|
||||
"sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
|
||||
},
|
||||
"heading": Object {
|
||||
"h1": "28px",
|
||||
"h2": "24px",
|
||||
"h3": "21px",
|
||||
"h4": "18px",
|
||||
"h5": "16px",
|
||||
"h6": "14px",
|
||||
},
|
||||
"lineHeight": Object {
|
||||
"lg": 1.5,
|
||||
"md": 1.3333333333333333,
|
||||
"sm": 1.1,
|
||||
"xs": 1,
|
||||
},
|
||||
"size": Object {
|
||||
"base": "13px",
|
||||
"lg": "18px",
|
||||
"md": "14px",
|
||||
"root": "14px",
|
||||
"sm": "12px",
|
||||
"xs": "10px",
|
||||
},
|
||||
"weight": Object {
|
||||
"light": 300,
|
||||
"regular": 400,
|
||||
"semibold": 500,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<PopperController
|
||||
content={
|
||||
<ColorPickerPopover
|
||||
color="#7EB26D"
|
||||
enableNamedColors={true}
|
||||
onChange={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"background": Object {
|
||||
"dropdown": "#1f1f20",
|
||||
"scrollbar": "#343436",
|
||||
"scrollbar2": "#343436",
|
||||
},
|
||||
"border": Object {
|
||||
"radius": Object {
|
||||
"lg": "5px",
|
||||
"md": "3px",
|
||||
"sm": "2px",
|
||||
},
|
||||
"width": Object {
|
||||
"sm": "1px",
|
||||
},
|
||||
},
|
||||
"breakpoints": Object {
|
||||
"lg": "992px",
|
||||
"md": "768px",
|
||||
"sm": "544px",
|
||||
"xl": "1200px",
|
||||
"xs": "0",
|
||||
},
|
||||
"colors": Object {
|
||||
"black": "#000000",
|
||||
"blue": "#33b5e5",
|
||||
"blueBase": "#3274d9",
|
||||
"blueFaint": "#041126",
|
||||
"blueLight": "#5794f2",
|
||||
"blueShade": "#1f60c4",
|
||||
"body": "#d8d9da",
|
||||
"bodyBg": "#161719",
|
||||
"brandDanger": "#e02f44",
|
||||
"brandPrimary": "#eb7b18",
|
||||
"brandSuccess": "#299c46",
|
||||
"brandWarning": "#eb7b18",
|
||||
"critical": "#e02f44",
|
||||
"dark1": "#141414",
|
||||
"dark10": "#424345",
|
||||
"dark2": "#161719",
|
||||
"dark3": "#1f1f20",
|
||||
"dark4": "#212124",
|
||||
"dark5": "#222426",
|
||||
"dark6": "#262628",
|
||||
"dark7": "#292a2d",
|
||||
"dark8": "#2f2f32",
|
||||
"dark9": "#343436",
|
||||
"gray1": "#555555",
|
||||
"gray2": "#8e8e8e",
|
||||
"gray3": "#b3b3b3",
|
||||
"gray4": "#d8d9da",
|
||||
"gray5": "#ececec",
|
||||
"gray6": "#f4f5f8",
|
||||
"gray7": "#fbfbfb",
|
||||
"grayBlue": "#212327",
|
||||
"greenBase": "#299c46",
|
||||
"greenShade": "#23843b",
|
||||
"headingColor": "#e3e3e3",
|
||||
"inputBlack": "#09090b",
|
||||
"link": "#e3e3e3",
|
||||
"linkDisabled": "#e3e3e3",
|
||||
"linkExternal": "#33b5e5",
|
||||
"linkHover": "#ffffff",
|
||||
"online": "#299c46",
|
||||
"orange": "#eb7b18",
|
||||
"pageBg": "#161719",
|
||||
"purple": "#9933cc",
|
||||
"queryGreen": "#74e680",
|
||||
"queryKeyword": "#66d9ef",
|
||||
"queryOrange": "#eb7b18",
|
||||
"queryPurple": "#fe85fc",
|
||||
"queryRed": "#e02f44",
|
||||
"red": "#d44a3a",
|
||||
"redBase": "#e02f44",
|
||||
"redShade": "#c4162a",
|
||||
"text": "#d8d9da",
|
||||
"textEmphasis": "#ececec",
|
||||
"textFaint": "#222426",
|
||||
"textStrong": "#ffffff",
|
||||
"textWeak": "#8e8e8e",
|
||||
"variable": "#32d1df",
|
||||
"warn": "#f79520",
|
||||
"white": "#ffffff",
|
||||
"yellow": "#ecbb13",
|
||||
},
|
||||
"name": "Grafana Dark",
|
||||
"panelPadding": Object {
|
||||
"horizontal": 10,
|
||||
"vertical": 5,
|
||||
},
|
||||
"spacing": Object {
|
||||
"d": "14px",
|
||||
"gutter": "30px",
|
||||
"lg": "24px",
|
||||
"md": "16px",
|
||||
"sm": "8px",
|
||||
"xl": "32px",
|
||||
"xs": "4px",
|
||||
"xxs": "2px",
|
||||
},
|
||||
"type": "dark",
|
||||
"typography": Object {
|
||||
"fontFamily": Object {
|
||||
"monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
|
||||
"sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
|
||||
},
|
||||
"heading": Object {
|
||||
"h1": "28px",
|
||||
"h2": "24px",
|
||||
"h3": "21px",
|
||||
"h4": "18px",
|
||||
"h5": "16px",
|
||||
"h6": "14px",
|
||||
},
|
||||
"lineHeight": Object {
|
||||
"lg": 1.5,
|
||||
"md": 1.3333333333333333,
|
||||
"sm": 1.1,
|
||||
"xs": 1,
|
||||
},
|
||||
"size": Object {
|
||||
"base": "13px",
|
||||
"lg": "18px",
|
||||
"md": "14px",
|
||||
"root": "14px",
|
||||
"sm": "12px",
|
||||
"xs": "10px",
|
||||
},
|
||||
"weight": Object {
|
||||
"light": 300,
|
||||
"regular": 400,
|
||||
"semibold": 500,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
hideAfter={300}
|
||||
>
|
||||
<ForwardRef(ColorPickerTrigger)
|
||||
color="#7EB26D"
|
||||
onClick={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
>
|
||||
<div
|
||||
onClick={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"background": "inherit",
|
||||
"border": "none",
|
||||
"borderRadius": 10,
|
||||
"color": "inherit",
|
||||
"cursor": "pointer",
|
||||
"overflow": "hidden",
|
||||
"padding": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundImage": "url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)",
|
||||
"border": "none",
|
||||
"float": "left",
|
||||
"height": 15,
|
||||
"margin": 0,
|
||||
"position": "relative",
|
||||
"width": 15,
|
||||
"zIndex": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "#7EB26D",
|
||||
"bottom": 0,
|
||||
"display": "block",
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ForwardRef(ColorPickerTrigger)>
|
||||
</PopperController>
|
||||
</ColorPicker>
|
||||
</WithTheme(ColorPicker)>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="thresholds-row-input-inner-value"
|
||||
>
|
||||
<input
|
||||
readOnly={true}
|
||||
type="text"
|
||||
value="Base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
</ThresholdsEditor>
|
||||
`;
|
||||
|
||||
@@ -25,6 +25,7 @@ export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
|
||||
export { Switch } from './Switch/Switch';
|
||||
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
|
||||
export { UnitPicker } from './UnitPicker/UnitPicker';
|
||||
export { Input, InputStatus } from './Input/Input';
|
||||
|
||||
// Visualizations
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './plugin';
|
||||
export * from './datasource';
|
||||
export * from './theme';
|
||||
export * from './threshold';
|
||||
export * from './input';
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface PluginMeta {
|
||||
includes: PluginInclude[];
|
||||
|
||||
// Datasource-specific
|
||||
builtIn?: boolean;
|
||||
metrics?: boolean;
|
||||
tables?: boolean;
|
||||
logs?: boolean;
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './string';
|
||||
export * from './displayValue';
|
||||
export * from './deprecationWarning';
|
||||
export { getMappedValue } from './valueMappings';
|
||||
export * from './validate';
|
||||
|
||||
24
packages/grafana-ui/src/utils/validate.ts
Normal file
24
packages/grafana-ui/src/utils/validate.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ValidationRule, ValidationEvents } from '../types/input';
|
||||
|
||||
export enum EventsWithValidation {
|
||||
onBlur = 'onBlur',
|
||||
onFocus = 'onFocus',
|
||||
onChange = 'onChange',
|
||||
}
|
||||
|
||||
export const validate = (value: string, validationRules: ValidationRule[]) => {
|
||||
const errors = validationRules.reduce(
|
||||
(acc, currRule) => {
|
||||
if (!currRule.rule(value)) {
|
||||
return acc.concat(currRule.errorMessage);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as string[]
|
||||
);
|
||||
return errors.length > 0 ? errors : null;
|
||||
};
|
||||
|
||||
export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents | undefined) => {
|
||||
return validationEvents && validationEvents[event];
|
||||
};
|
||||
@@ -14,6 +14,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
|
||||
reqEditorRole := middleware.ReqEditorRole
|
||||
reqOrgAdmin := middleware.ReqOrgAdmin
|
||||
reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin)
|
||||
redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
|
||||
redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
|
||||
quota := middleware.Quota(hs.QuotaService)
|
||||
@@ -41,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/org/users", reqOrgAdmin, hs.Index)
|
||||
r.Get("/org/users/new", reqOrgAdmin, hs.Index)
|
||||
r.Get("/org/users/invite", reqOrgAdmin, hs.Index)
|
||||
r.Get("/org/teams", reqOrgAdmin, hs.Index)
|
||||
r.Get("/org/teams/*", reqOrgAdmin, hs.Index)
|
||||
r.Get("/org/teams", reqCanAccessTeams, hs.Index)
|
||||
r.Get("/org/teams/*", reqCanAccessTeams, hs.Index)
|
||||
r.Get("/org/apikeys/", reqOrgAdmin, hs.Index)
|
||||
r.Get("/dashboard/import/", reqSignedIn, hs.Index)
|
||||
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
|
||||
@@ -153,20 +154,21 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
|
||||
// team (admin permission required)
|
||||
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
|
||||
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(CreateTeam))
|
||||
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam))
|
||||
teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID))
|
||||
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam))
|
||||
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam))
|
||||
teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID))
|
||||
teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers))
|
||||
teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember))
|
||||
teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember))
|
||||
teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences))
|
||||
teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences))
|
||||
}, reqOrgAdmin)
|
||||
teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember))
|
||||
teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember))
|
||||
teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember))
|
||||
teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences))
|
||||
teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences))
|
||||
}, reqCanAccessTeams)
|
||||
|
||||
// team without requirement of user to be org admin
|
||||
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
|
||||
teamsRoute.Get("/:teamId", Wrap(GetTeamByID))
|
||||
teamsRoute.Get("/search", Wrap(SearchTeams))
|
||||
teamsRoute.Get("/search", Wrap(hs.SearchTeams))
|
||||
})
|
||||
|
||||
// org information available to all users.
|
||||
@@ -265,7 +267,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
|
||||
folderRoute.Get("/", Wrap(GetFolders))
|
||||
folderRoute.Get("/id/:id", Wrap(GetFolderByID))
|
||||
folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(CreateFolder))
|
||||
folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder))
|
||||
|
||||
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
|
||||
folderUidRoute.Get("/", Wrap(GetFolderByUID))
|
||||
|
||||
@@ -213,7 +213,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
|
||||
|
||||
dash := cmd.GetDashboardModel()
|
||||
|
||||
if dash.Id == 0 && dash.Uid == "" {
|
||||
newDashboard := dash.Id == 0 && dash.Uid == ""
|
||||
if newDashboard {
|
||||
limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard")
|
||||
if err != nil {
|
||||
return Error(500, "failed to get quota", err)
|
||||
@@ -276,6 +277,15 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
|
||||
return Error(500, "Failed to save dashboard", err)
|
||||
}
|
||||
|
||||
if hs.Cfg.EditorsCanAdmin && newDashboard {
|
||||
inFolder := cmd.FolderId > 0
|
||||
err := dashboards.MakeUserAdmin(hs.Bus, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder)
|
||||
if err != nil {
|
||||
hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
|
||||
return Error(500, "Failed to make user admin of dashboard", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.TimeRequest(metrics.M_Api_Dashboard_Save)
|
||||
return JSON(200, util.DynMap{
|
||||
"status": "success",
|
||||
|
||||
@@ -974,6 +974,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d
|
||||
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(url)
|
||||
@@ -1024,6 +1025,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
hs := HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
Bus: bus.GetBus(),
|
||||
}
|
||||
|
||||
|
||||
@@ -54,13 +54,20 @@ func GetFolderByID(c *m.ReqContext) Response {
|
||||
return JSON(200, toFolderDto(g, folder))
|
||||
}
|
||||
|
||||
func CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
|
||||
func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
|
||||
s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
|
||||
err := s.CreateFolder(&cmd)
|
||||
if err != nil {
|
||||
return toFolderError(err)
|
||||
}
|
||||
|
||||
if hs.Cfg.EditorsCanAdmin {
|
||||
if err := dashboards.MakeUserAdmin(hs.Bus, c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil {
|
||||
hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
|
||||
return Error(500, "Failed to make user admin of folder", err)
|
||||
}
|
||||
}
|
||||
|
||||
g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
|
||||
return JSON(200, toFolderDto(g, cmd.Result))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
@@ -141,12 +142,17 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa
|
||||
Convey(desc+" "+url, func() {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
hs := HTTPServer{
|
||||
Bus: bus.GetBus(),
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
|
||||
sc := setupScenarioContext(url)
|
||||
sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
|
||||
sc.context = c
|
||||
sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
|
||||
|
||||
return CreateFolder(c, cmd)
|
||||
return hs.CreateFolder(c, cmd)
|
||||
})
|
||||
|
||||
origNewFolderService := dashboards.NewFolderService
|
||||
|
||||
@@ -167,7 +167,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
|
||||
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
|
||||
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
|
||||
"viewersCanEdit": setting.ViewersCanEdit,
|
||||
"editorsCanOwn": hs.Cfg.EditorsCanOwn,
|
||||
"editorsCanAdmin": hs.Cfg.EditorsCanAdmin,
|
||||
"disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
|
||||
"buildInfo": map[string]interface{}{
|
||||
"version": setting.BuildVersion,
|
||||
|
||||
@@ -327,6 +327,27 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
|
||||
})
|
||||
}
|
||||
|
||||
if (c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_VIEWER) && hs.Cfg.EditorsCanAdmin {
|
||||
cfgNode := &dtos.NavLink{
|
||||
Id: "cfg",
|
||||
Text: "Configuration",
|
||||
SubTitle: "Organization: " + c.OrgName,
|
||||
Icon: "gicon gicon-cog",
|
||||
Url: setting.AppSubUrl + "/org/teams",
|
||||
Children: []*dtos.NavLink{
|
||||
{
|
||||
Text: "Teams",
|
||||
Id: "teams",
|
||||
Description: "Manage org groups",
|
||||
Icon: "gicon gicon-team",
|
||||
Url: setting.AppSubUrl + "/org/teams",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data.NavTree = append(data.NavTree, cfgNode)
|
||||
}
|
||||
|
||||
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||
Text: "Help",
|
||||
SubTitle: fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit),
|
||||
|
||||
@@ -4,19 +4,38 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/teamguardian"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
// POST /api/teams
|
||||
func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
|
||||
func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
|
||||
cmd.OrgId = c.OrgId
|
||||
if err := bus.Dispatch(&cmd); err != nil {
|
||||
|
||||
if c.OrgRole == m.ROLE_VIEWER {
|
||||
return Error(403, "Not allowed to create team.", nil)
|
||||
}
|
||||
|
||||
if err := hs.Bus.Dispatch(&cmd); err != nil {
|
||||
if err == m.ErrTeamNameTaken {
|
||||
return Error(409, "Team name taken", err)
|
||||
}
|
||||
return Error(500, "Failed to create Team", err)
|
||||
}
|
||||
|
||||
if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin {
|
||||
addMemberCmd := m.AddTeamMemberCommand{
|
||||
UserId: c.SignedInUser.UserId,
|
||||
OrgId: cmd.OrgId,
|
||||
TeamId: cmd.Result.Id,
|
||||
Permission: m.PERMISSION_ADMIN,
|
||||
}
|
||||
|
||||
if err := hs.Bus.Dispatch(&addMemberCmd); err != nil {
|
||||
c.Logger.Error("Could not add creator to team.", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return JSON(200, &util.DynMap{
|
||||
"teamId": cmd.Result.Id,
|
||||
"message": "Team created",
|
||||
@@ -24,10 +43,15 @@ func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
|
||||
}
|
||||
|
||||
// PUT /api/teams/:teamId
|
||||
func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
|
||||
func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
|
||||
cmd.OrgId = c.OrgId
|
||||
cmd.Id = c.ParamsInt64(":teamId")
|
||||
if err := bus.Dispatch(&cmd); err != nil {
|
||||
|
||||
if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.Id, c.SignedInUser); err != nil {
|
||||
return Error(403, "Not allowed to update team", err)
|
||||
}
|
||||
|
||||
if err := hs.Bus.Dispatch(&cmd); err != nil {
|
||||
if err == m.ErrTeamNameTaken {
|
||||
return Error(400, "Team name taken", err)
|
||||
}
|
||||
@@ -38,18 +62,26 @@ func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
|
||||
}
|
||||
|
||||
// DELETE /api/teams/:teamId
|
||||
func DeleteTeamByID(c *m.ReqContext) Response {
|
||||
if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil {
|
||||
func (hs *HTTPServer) DeleteTeamByID(c *m.ReqContext) Response {
|
||||
orgId := c.OrgId
|
||||
teamId := c.ParamsInt64(":teamId")
|
||||
user := c.SignedInUser
|
||||
|
||||
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, user); err != nil {
|
||||
return Error(403, "Not allowed to delete team", err)
|
||||
}
|
||||
|
||||
if err := hs.Bus.Dispatch(&m.DeleteTeamCommand{OrgId: orgId, Id: teamId}); err != nil {
|
||||
if err == m.ErrTeamNotFound {
|
||||
return Error(404, "Failed to delete Team. ID not found", nil)
|
||||
}
|
||||
return Error(500, "Failed to update Team", err)
|
||||
return Error(500, "Failed to delete Team", err)
|
||||
}
|
||||
return Success("Team deleted")
|
||||
}
|
||||
|
||||
// GET /api/teams/search
|
||||
func SearchTeams(c *m.ReqContext) Response {
|
||||
func (hs *HTTPServer) SearchTeams(c *m.ReqContext) Response {
|
||||
perPage := c.QueryInt("perpage")
|
||||
if perPage <= 0 {
|
||||
perPage = 1000
|
||||
@@ -59,10 +91,16 @@ func SearchTeams(c *m.ReqContext) Response {
|
||||
page = 1
|
||||
}
|
||||
|
||||
var userIdFilter int64
|
||||
if hs.Cfg.EditorsCanAdmin && c.OrgRole != m.ROLE_ADMIN {
|
||||
userIdFilter = c.SignedInUser.UserId
|
||||
}
|
||||
|
||||
query := m.SearchTeamsQuery{
|
||||
OrgId: c.OrgId,
|
||||
Query: c.Query("query"),
|
||||
Name: c.Query("name"),
|
||||
UserIdFilter: userIdFilter,
|
||||
Page: page,
|
||||
Limit: perPage,
|
||||
}
|
||||
@@ -98,11 +136,25 @@ func GetTeamByID(c *m.ReqContext) Response {
|
||||
}
|
||||
|
||||
// GET /api/teams/:teamId/preferences
|
||||
func GetTeamPreferences(c *m.ReqContext) Response {
|
||||
return getPreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"))
|
||||
func (hs *HTTPServer) GetTeamPreferences(c *m.ReqContext) Response {
|
||||
teamId := c.ParamsInt64(":teamId")
|
||||
orgId := c.OrgId
|
||||
|
||||
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
|
||||
return Error(403, "Not allowed to view team preferences.", err)
|
||||
}
|
||||
|
||||
return getPreferencesFor(orgId, 0, teamId)
|
||||
}
|
||||
|
||||
// PUT /api/teams/:teamId/preferences
|
||||
func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
|
||||
return updatePreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"), &dtoCmd)
|
||||
func (hs *HTTPServer) UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
|
||||
teamId := c.ParamsInt64(":teamId")
|
||||
orgId := c.OrgId
|
||||
|
||||
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
|
||||
return Error(403, "Not allowed to update team preferences.", err)
|
||||
}
|
||||
|
||||
return updatePreferencesFor(orgId, 0, teamId, &dtoCmd)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/teamguardian"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@@ -29,11 +30,15 @@ func GetTeamMembers(c *m.ReqContext) Response {
|
||||
}
|
||||
|
||||
// POST /api/teams/:teamId/members
|
||||
func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
|
||||
cmd.TeamId = c.ParamsInt64(":teamId")
|
||||
func (hs *HTTPServer) AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
|
||||
cmd.OrgId = c.OrgId
|
||||
cmd.TeamId = c.ParamsInt64(":teamId")
|
||||
|
||||
if err := bus.Dispatch(&cmd); err != nil {
|
||||
if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil {
|
||||
return Error(403, "Not allowed to add team member", err)
|
||||
}
|
||||
|
||||
if err := hs.Bus.Dispatch(&cmd); err != nil {
|
||||
if err == m.ErrTeamNotFound {
|
||||
return Error(404, "Team not found", nil)
|
||||
}
|
||||
@@ -50,9 +55,48 @@ func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /:teamId/members/:userId
|
||||
func (hs *HTTPServer) UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response {
|
||||
teamId := c.ParamsInt64(":teamId")
|
||||
orgId := c.OrgId
|
||||
|
||||
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
|
||||
return Error(403, "Not allowed to update team member", err)
|
||||
}
|
||||
|
||||
if c.OrgRole != m.ROLE_ADMIN {
|
||||
cmd.ProtectLastAdmin = true
|
||||
}
|
||||
|
||||
cmd.TeamId = teamId
|
||||
cmd.UserId = c.ParamsInt64(":userId")
|
||||
cmd.OrgId = orgId
|
||||
|
||||
if err := hs.Bus.Dispatch(&cmd); err != nil {
|
||||
if err == m.ErrTeamMemberNotFound {
|
||||
return Error(404, "Team member not found.", nil)
|
||||
}
|
||||
return Error(500, "Failed to update team member.", err)
|
||||
}
|
||||
return Success("Team member updated")
|
||||
}
|
||||
|
||||
// DELETE /api/teams/:teamId/members/:userId
|
||||
func RemoveTeamMember(c *m.ReqContext) Response {
|
||||
if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil {
|
||||
func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response {
|
||||
orgId := c.OrgId
|
||||
teamId := c.ParamsInt64(":teamId")
|
||||
userId := c.ParamsInt64(":userId")
|
||||
|
||||
if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
|
||||
return Error(403, "Not allowed to remove team member", err)
|
||||
}
|
||||
|
||||
protectLastAdmin := false
|
||||
if c.OrgRole != m.ROLE_ADMIN {
|
||||
protectLastAdmin = true
|
||||
}
|
||||
|
||||
if err := hs.Bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId, ProtectLastAdmin: protectLastAdmin}); err != nil {
|
||||
if err == m.ErrTeamNotFound {
|
||||
return Error(404, "Team not found", nil)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package api
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
@@ -20,6 +22,10 @@ func TestTeamApiEndpoint(t *testing.T) {
|
||||
TotalCount: 2,
|
||||
}
|
||||
|
||||
hs := &HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
|
||||
Convey("When searching with no parameters", func() {
|
||||
loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
|
||||
var sentLimit int
|
||||
@@ -33,7 +39,7 @@ func TestTeamApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.handlerFunc = SearchTeams
|
||||
sc.handlerFunc = hs.SearchTeams
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
So(sentLimit, ShouldEqual, 1000)
|
||||
@@ -60,7 +66,7 @@ func TestTeamApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.handlerFunc = SearchTeams
|
||||
sc.handlerFunc = hs.SearchTeams
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
|
||||
|
||||
So(sentLimit, ShouldEqual, 10)
|
||||
|
||||
@@ -86,3 +86,20 @@ func Auth(options *AuthOptions) macaron.Handler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOrFeatureEnabled creates a middleware that allows access
|
||||
// if the signed in user is either an Org Admin or if the
|
||||
// feature flag is enabled.
|
||||
// Intended for when feature flags open up access to APIs that
|
||||
// are otherwise only available to admins.
|
||||
func AdminOrFeatureEnabled(enabled bool) macaron.Handler {
|
||||
return func(c *m.ReqContext) {
|
||||
if c.OrgRole == m.ROLE_ADMIN {
|
||||
return
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
accessForbidden(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ var (
|
||||
ErrTeamNotFound = errors.New("Team not found")
|
||||
ErrTeamNameTaken = errors.New("Team name is taken")
|
||||
ErrTeamMemberNotFound = errors.New("Team member not found")
|
||||
ErrLastTeamAdmin = errors.New("Not allowed to remove last admin")
|
||||
ErrNotAllowedToUpdateTeam = errors.New("User not allowed to update team")
|
||||
ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("User not allowed to update team in another org")
|
||||
)
|
||||
|
||||
// Team model
|
||||
@@ -64,6 +67,7 @@ type SearchTeamsQuery struct {
|
||||
Limit int
|
||||
Page int
|
||||
OrgId int64
|
||||
UserIdFilter int64
|
||||
|
||||
Result SearchTeamQueryResult
|
||||
}
|
||||
@@ -75,6 +79,7 @@ type TeamDTO struct {
|
||||
Email string `json:"email"`
|
||||
AvatarUrl string `json:"avatarUrl"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
Permission PermissionType `json:"permission"`
|
||||
}
|
||||
|
||||
type SearchTeamQueryResult struct {
|
||||
|
||||
@@ -16,7 +16,8 @@ type TeamMember struct {
|
||||
OrgId int64
|
||||
TeamId int64
|
||||
UserId int64
|
||||
External bool
|
||||
External bool // Signals that the membership has been created by an external systems, such as LDAP
|
||||
Permission PermissionType
|
||||
|
||||
Created time.Time
|
||||
Updated time.Time
|
||||
@@ -30,12 +31,22 @@ type AddTeamMemberCommand struct {
|
||||
OrgId int64 `json:"-"`
|
||||
TeamId int64 `json:"-"`
|
||||
External bool `json:"-"`
|
||||
Permission PermissionType `json:"-"`
|
||||
}
|
||||
|
||||
type UpdateTeamMemberCommand struct {
|
||||
UserId int64 `json:"-"`
|
||||
OrgId int64 `json:"-"`
|
||||
TeamId int64 `json:"-"`
|
||||
Permission PermissionType `json:"permission"`
|
||||
ProtectLastAdmin bool `json:"-"`
|
||||
}
|
||||
|
||||
type RemoveTeamMemberCommand struct {
|
||||
OrgId int64 `json:"-"`
|
||||
UserId int64
|
||||
TeamId int64
|
||||
ProtectLastAdmin bool `json:"-"`
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
@@ -61,4 +72,5 @@ type TeamMemberDTO struct {
|
||||
Login string `json:"login"`
|
||||
AvatarUrl string `json:"avatarUrl"`
|
||||
Labels []string `json:"labels"`
|
||||
Permission PermissionType `json:"permission"`
|
||||
}
|
||||
|
||||
55
pkg/services/dashboards/acl_service.go
Normal file
55
pkg/services/dashboards/acl_service.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package dashboards
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
func MakeUserAdmin(bus bus.Bus, orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error {
|
||||
rtEditor := models.ROLE_EDITOR
|
||||
rtViewer := models.ROLE_VIEWER
|
||||
|
||||
items := []*models.DashboardAcl{
|
||||
{
|
||||
OrgId: orgId,
|
||||
DashboardId: dashboardId,
|
||||
UserId: userId,
|
||||
Permission: models.PERMISSION_ADMIN,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
if setViewAndEditPermissions {
|
||||
items = append(items,
|
||||
&models.DashboardAcl{
|
||||
OrgId: orgId,
|
||||
DashboardId: dashboardId,
|
||||
Role: &rtEditor,
|
||||
Permission: models.PERMISSION_EDIT,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
},
|
||||
&models.DashboardAcl{
|
||||
OrgId: orgId,
|
||||
DashboardId: dashboardId,
|
||||
Role: &rtViewer,
|
||||
Permission: models.PERMISSION_VIEW,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
aclCmd := &models.UpdateDashboardAclCommand{
|
||||
DashboardId: dashboardId,
|
||||
Items: items,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(aclCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -54,4 +54,8 @@ func addTeamMigrations(mg *Migrator) {
|
||||
mg.AddMigration("Add column external to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
|
||||
Name: "external", Type: DB_Bool, Nullable: true,
|
||||
}))
|
||||
|
||||
mg.AddMigration("Add column permission to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
|
||||
Name: "permission", Type: DB_SmallInt, Nullable: true,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -18,10 +18,23 @@ func init() {
|
||||
bus.AddHandler("sql", GetTeamsByUser)
|
||||
|
||||
bus.AddHandler("sql", AddTeamMember)
|
||||
bus.AddHandler("sql", UpdateTeamMember)
|
||||
bus.AddHandler("sql", RemoveTeamMember)
|
||||
bus.AddHandler("sql", GetTeamMembers)
|
||||
}
|
||||
|
||||
func getTeamSearchSqlBase() string {
|
||||
return `SELECT
|
||||
team.id as id,
|
||||
team.org_id,
|
||||
team.name as name,
|
||||
team.email as email,
|
||||
(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count,
|
||||
team_member.permission
|
||||
FROM team as team
|
||||
INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ? `
|
||||
}
|
||||
|
||||
func getTeamSelectSqlBase() string {
|
||||
return `SELECT
|
||||
team.id as id,
|
||||
@@ -91,10 +104,8 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
|
||||
// DeleteTeam will delete a team, its member and any permissions connected to the team
|
||||
func DeleteTeam(cmd *m.DeleteTeamCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
|
||||
if _, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
|
||||
return err
|
||||
} else if !teamExists {
|
||||
return m.ErrTeamNotFound
|
||||
}
|
||||
|
||||
deletes := []string{
|
||||
@@ -117,7 +128,7 @@ func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) {
|
||||
if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil {
|
||||
return false, err
|
||||
} else if len(res) != 1 {
|
||||
return false, nil
|
||||
return false, m.ErrTeamNotFound
|
||||
}
|
||||
|
||||
return true, nil
|
||||
@@ -147,7 +158,12 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
|
||||
var sql bytes.Buffer
|
||||
params := make([]interface{}, 0)
|
||||
|
||||
if query.UserIdFilter > 0 {
|
||||
sql.WriteString(getTeamSearchSqlBase())
|
||||
params = append(params, query.UserIdFilter)
|
||||
} else {
|
||||
sql.WriteString(getTeamSelectSqlBase())
|
||||
}
|
||||
sql.WriteString(` WHERE team.org_id = ?`)
|
||||
|
||||
params = append(params, query.OrgId)
|
||||
@@ -233,10 +249,8 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
|
||||
return m.ErrTeamMemberAlreadyAdded
|
||||
}
|
||||
|
||||
if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
|
||||
if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
|
||||
return err
|
||||
} else if !teamExists {
|
||||
return m.ErrTeamNotFound
|
||||
}
|
||||
|
||||
entity := m.TeamMember{
|
||||
@@ -246,6 +260,7 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
|
||||
External: cmd.External,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
Permission: cmd.Permission,
|
||||
}
|
||||
|
||||
_, err := sess.Insert(&entity)
|
||||
@@ -253,13 +268,59 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
func getTeamMember(sess *DBSession, orgId int64, teamId int64, userId int64) (m.TeamMember, error) {
|
||||
rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?`
|
||||
var member m.TeamMember
|
||||
exists, err := sess.SQL(rawSql, orgId, teamId, userId).Get(&member)
|
||||
|
||||
if err != nil {
|
||||
return member, err
|
||||
}
|
||||
if !exists {
|
||||
return member, m.ErrTeamMemberNotFound
|
||||
}
|
||||
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// UpdateTeamMember updates a team member
|
||||
func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
member, err := getTeamMember(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cmd.ProtectLastAdmin {
|
||||
_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Permission != m.PERMISSION_ADMIN { // make sure we don't get invalid permission levels in store
|
||||
cmd.Permission = 0
|
||||
}
|
||||
|
||||
member.Permission = cmd.Permission
|
||||
_, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveTeamMember removes a member from a team
|
||||
func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
|
||||
if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
|
||||
return err
|
||||
} else if !teamExists {
|
||||
return m.ErrTeamNotFound
|
||||
}
|
||||
|
||||
if cmd.ProtectLastAdmin {
|
||||
_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?"
|
||||
@@ -276,6 +337,29 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
func isLastAdmin(sess *DBSession, orgId int64, teamId int64, userId int64) (bool, error) {
|
||||
rawSql := "SELECT user_id FROM team_member WHERE org_id=? and team_id=? and permission=?"
|
||||
userIds := []*int64{}
|
||||
err := sess.SQL(rawSql, orgId, teamId, m.PERMISSION_ADMIN).Find(&userIds)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
for _, adminId := range userIds {
|
||||
if userId == *adminId {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isAdmin && len(userIds) == 1 {
|
||||
return true, m.ErrLastTeamAdmin
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
// GetTeamMembers return a list of members for the specified team
|
||||
func GetTeamMembers(query *m.GetTeamMembersQuery) error {
|
||||
query.Result = make([]*m.TeamMemberDTO, 0)
|
||||
@@ -293,7 +377,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
|
||||
if query.External {
|
||||
sess.Where("team_member.external=?", dialect.BooleanStr(true))
|
||||
}
|
||||
sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external")
|
||||
sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external", "team_member.permission")
|
||||
sess.Asc("user.login", "user.email")
|
||||
|
||||
err := sess.Find(&query.Result)
|
||||
|
||||
@@ -75,6 +75,72 @@ func TestTeamCommandsAndQueries(t *testing.T) {
|
||||
So(q2.Result[0].External, ShouldEqual, true)
|
||||
})
|
||||
|
||||
Convey("Should be able to update users in a team", func() {
|
||||
userId := userIds[0]
|
||||
team := group1.Result
|
||||
addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userId}
|
||||
err = AddTeamMember(&addMemberCmd)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
|
||||
err = GetTeamMembers(qBeforeUpdate)
|
||||
So(err, ShouldBeNil)
|
||||
So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
|
||||
|
||||
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
|
||||
UserId: userId,
|
||||
OrgId: testOrgId,
|
||||
TeamId: team.Id,
|
||||
Permission: m.PERMISSION_ADMIN,
|
||||
})
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
|
||||
err = GetTeamMembers(qAfterUpdate)
|
||||
So(err, ShouldBeNil)
|
||||
So(qAfterUpdate.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
|
||||
})
|
||||
|
||||
Convey("Should default to member permission level when updating a user with invalid permission level", func() {
|
||||
userID := userIds[0]
|
||||
team := group1.Result
|
||||
addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userID}
|
||||
err = AddTeamMember(&addMemberCmd)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
|
||||
err = GetTeamMembers(qBeforeUpdate)
|
||||
So(err, ShouldBeNil)
|
||||
So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
|
||||
|
||||
invalidPermissionLevel := m.PERMISSION_EDIT
|
||||
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
|
||||
UserId: userID,
|
||||
OrgId: testOrgId,
|
||||
TeamId: team.Id,
|
||||
Permission: invalidPermissionLevel,
|
||||
})
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
|
||||
err = GetTeamMembers(qAfterUpdate)
|
||||
So(err, ShouldBeNil)
|
||||
So(qAfterUpdate.Result[0].Permission, ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("Shouldn't be able to update a user not in the team.", func() {
|
||||
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
|
||||
UserId: 1,
|
||||
OrgId: testOrgId,
|
||||
TeamId: group1.Result.Id,
|
||||
Permission: m.PERMISSION_ADMIN,
|
||||
})
|
||||
|
||||
So(err, ShouldEqual, m.ErrTeamMemberNotFound)
|
||||
})
|
||||
|
||||
Convey("Should be able to search for teams", func() {
|
||||
query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1}
|
||||
err = SearchTeams(query)
|
||||
@@ -114,6 +180,33 @@ func TestTeamCommandsAndQueries(t *testing.T) {
|
||||
So(len(q2.Result), ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("When ProtectLastAdmin is set to true", func() {
|
||||
err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: m.PERMISSION_ADMIN})
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("A user should not be able to remove the last admin", func() {
|
||||
err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
|
||||
So(err, ShouldEqual, m.ErrLastTeamAdmin)
|
||||
})
|
||||
|
||||
Convey("A user should be able to remove an admin if there are other admins", func() {
|
||||
AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
|
||||
err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
|
||||
So(err, ShouldEqual, nil)
|
||||
})
|
||||
|
||||
Convey("A user should not be able to remove the admin permission for the last admin", func() {
|
||||
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
|
||||
So(err, ShouldEqual, m.ErrLastTeamAdmin)
|
||||
})
|
||||
|
||||
Convey("A user should be able to remove the admin permission if there are other admins", func() {
|
||||
AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
|
||||
err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
|
||||
So(err, ShouldEqual, nil)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Should be able to remove a group with users and permissions", func() {
|
||||
groupId := group2.Result.Id
|
||||
err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]})
|
||||
|
||||
34
pkg/services/teamguardian/team.go
Normal file
34
pkg/services/teamguardian/team.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package teamguardian
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
func CanAdmin(bus bus.Bus, orgId int64, teamId int64, user *m.SignedInUser) error {
|
||||
if user.OrgRole == m.ROLE_ADMIN {
|
||||
return nil
|
||||
}
|
||||
|
||||
if user.OrgId != orgId {
|
||||
return m.ErrNotAllowedToUpdateTeamInDifferentOrg
|
||||
}
|
||||
|
||||
cmd := m.GetTeamMembersQuery{
|
||||
OrgId: orgId,
|
||||
TeamId: teamId,
|
||||
UserId: user.UserId,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, member := range cmd.Result {
|
||||
if member.UserId == user.UserId && member.Permission == m.PERMISSION_ADMIN {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return m.ErrNotAllowedToUpdateTeam
|
||||
}
|
||||
87
pkg/services/teamguardian/teams_test.go
Normal file
87
pkg/services/teamguardian/teams_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package teamguardian
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateTeam(t *testing.T) {
|
||||
Convey("Updating a team", t, func() {
|
||||
bus.ClearBusHandlers()
|
||||
|
||||
admin := m.SignedInUser{
|
||||
UserId: 1,
|
||||
OrgId: 1,
|
||||
OrgRole: m.ROLE_ADMIN,
|
||||
}
|
||||
editor := m.SignedInUser{
|
||||
UserId: 2,
|
||||
OrgId: 1,
|
||||
OrgRole: m.ROLE_EDITOR,
|
||||
}
|
||||
testTeam := m.Team{
|
||||
Id: 1,
|
||||
OrgId: 1,
|
||||
}
|
||||
|
||||
Convey("Given an editor and a team he isn't a member of", func() {
|
||||
Convey("Should not be able to update the team", func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
|
||||
cmd.Result = []*m.TeamMemberDTO{}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
|
||||
So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given an editor and a team he is an admin in", func() {
|
||||
Convey("Should be able to update the team", func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
|
||||
cmd.Result = []*m.TeamMemberDTO{{
|
||||
OrgId: testTeam.OrgId,
|
||||
TeamId: testTeam.Id,
|
||||
UserId: editor.UserId,
|
||||
Permission: m.PERMISSION_ADMIN,
|
||||
}}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given an editor and a team in another org", func() {
|
||||
testTeamOtherOrg := m.Team{
|
||||
Id: 1,
|
||||
OrgId: 2,
|
||||
}
|
||||
|
||||
Convey("Shouldn't be able to update the team", func() {
|
||||
bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
|
||||
cmd.Result = []*m.TeamMemberDTO{{
|
||||
OrgId: testTeamOtherOrg.OrgId,
|
||||
TeamId: testTeamOtherOrg.Id,
|
||||
UserId: editor.UserId,
|
||||
Permission: m.PERMISSION_ADMIN,
|
||||
}}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := CanAdmin(bus.GetBus(), testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor)
|
||||
So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given an org admin and a team", func() {
|
||||
Convey("Should be able to update the team", func() {
|
||||
err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &admin)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -239,14 +239,13 @@ type Cfg struct {
|
||||
LoginMaxLifetimeDays int
|
||||
TokenRotationIntervalMinutes int
|
||||
|
||||
// User
|
||||
EditorsCanOwn bool
|
||||
|
||||
// Dataproxy
|
||||
SendUserHeader bool
|
||||
|
||||
// DistributedCache
|
||||
RemoteCacheOptions *RemoteCacheOptions
|
||||
|
||||
EditorsCanAdmin bool
|
||||
}
|
||||
|
||||
type CommandLineArgs struct {
|
||||
@@ -670,7 +669,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
||||
ExternalUserMngLinkName = users.Key("external_manage_link_name").String()
|
||||
ExternalUserMngInfo = users.Key("external_manage_info").String()
|
||||
ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false)
|
||||
cfg.EditorsCanOwn = users.Key("editors_can_own").MustBool(false)
|
||||
cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false)
|
||||
|
||||
// auth
|
||||
auth := iniFile.Section("auth")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { Input } from './Input';
|
||||
13
public/app/core/components/WithFeatureToggle.tsx
Normal file
13
public/app/core/components/WithFeatureToggle.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React, { FunctionComponent } from 'react';
|
||||
|
||||
export interface Props {
|
||||
featureToggle: boolean;
|
||||
}
|
||||
|
||||
export const WithFeatureToggle: FunctionComponent<Props> = ({ featureToggle, children }) => {
|
||||
if (featureToggle === true) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -12,6 +12,7 @@ const setup = (propOverrides?: object) => {
|
||||
{
|
||||
link: {},
|
||||
user: {
|
||||
id: 1,
|
||||
isGrafanaAdmin: false,
|
||||
isSignedIn: false,
|
||||
orgCount: 2,
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface BuildInfo {
|
||||
|
||||
export class Settings {
|
||||
datasources: any;
|
||||
panels: PanelPlugin[];
|
||||
panels: { [key: string]: PanelPlugin };
|
||||
appSubUrl: string;
|
||||
windowTitlePrefix: string;
|
||||
buildInfo: BuildInfo;
|
||||
@@ -37,7 +37,7 @@ export class Settings {
|
||||
passwordHint: any;
|
||||
loginError: any;
|
||||
viewersCanEdit: boolean;
|
||||
editorsCanOwn: boolean;
|
||||
editorsCanAdmin: boolean;
|
||||
disableSanitizeHtml: boolean;
|
||||
theme: GrafanaTheme;
|
||||
|
||||
@@ -59,7 +59,7 @@ export class Settings {
|
||||
isEnterprise: false,
|
||||
},
|
||||
viewersCanEdit: false,
|
||||
editorsCanOwn: false,
|
||||
editorsCanAdmin: false,
|
||||
disableSanitizeHtml: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import _ from 'lodash';
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
export class User {
|
||||
id: number;
|
||||
isGrafanaAdmin: any;
|
||||
isSignedIn: any;
|
||||
orgRole: any;
|
||||
|
||||
@@ -9,6 +9,7 @@ import store from 'app/core/store';
|
||||
import { parse as parseDate } from 'app/core/utils/datemath';
|
||||
import { colors } from '@grafana/ui';
|
||||
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
|
||||
import { getNextRefIdChar } from './query';
|
||||
|
||||
// Types
|
||||
import { RawTimeRange, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui';
|
||||
@@ -225,12 +226,8 @@ export function generateKey(index = 0): string {
|
||||
return `Q-${Date.now()}-${Math.random()}-${index}`;
|
||||
}
|
||||
|
||||
export function generateRefId(index = 0): string {
|
||||
return `${index + 1}`;
|
||||
}
|
||||
|
||||
export function generateEmptyQuery(index = 0): { refId: string; key: string } {
|
||||
return { refId: generateRefId(index), key: generateKey(index) };
|
||||
export function generateEmptyQuery(queries: DataQuery[], index = 0): DataQuery {
|
||||
return { refId: getNextRefIdChar(queries), key: generateKey(index) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,9 +235,9 @@ export function generateEmptyQuery(index = 0): { refId: string; key: string } {
|
||||
*/
|
||||
export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
|
||||
if (queries && typeof queries === 'object' && queries.length > 0) {
|
||||
return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) }));
|
||||
return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(queries, i) }));
|
||||
}
|
||||
return [{ ...generateEmptyQuery() }];
|
||||
return [{ ...generateEmptyQuery(queries) }];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
30
public/app/core/utils/query.test.ts
Normal file
30
public/app/core/utils/query.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { DataQuery } from '@grafana/ui';
|
||||
import { getNextRefIdChar } from './query';
|
||||
|
||||
const dataQueries: DataQuery[] = [
|
||||
{
|
||||
refId: 'A',
|
||||
},
|
||||
{
|
||||
refId: 'B',
|
||||
},
|
||||
{
|
||||
refId: 'C',
|
||||
},
|
||||
{
|
||||
refId: 'D',
|
||||
},
|
||||
{
|
||||
refId: 'E',
|
||||
},
|
||||
];
|
||||
|
||||
describe('Get next refId char', () => {
|
||||
it('should return next char', () => {
|
||||
expect(getNextRefIdChar(dataQueries)).toEqual('F');
|
||||
});
|
||||
|
||||
it('should get first char', () => {
|
||||
expect(getNextRefIdChar([])).toEqual('A');
|
||||
});
|
||||
});
|
||||
12
public/app/core/utils/query.ts
Normal file
12
public/app/core/utils/query.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import _ from 'lodash';
|
||||
import { DataQuery } from '@grafana/ui/';
|
||||
|
||||
export const getNextRefIdChar = (queries: DataQuery[]): string => {
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
return _.find(letters, refId => {
|
||||
return _.every(queries, other => {
|
||||
return other.refId !== refId;
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ValidationRule, ValidationEvents } from 'app/types';
|
||||
import { EventsWithValidation } from 'app/core/components/Form/Input';
|
||||
|
||||
export const validate = (value: string, validationRules: ValidationRule[]) => {
|
||||
const errors = validationRules.reduce((acc, currRule) => {
|
||||
if (!currRule.rule(value)) {
|
||||
return acc.concat(currRule.errorMessage);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
return errors.length > 0 ? errors : null;
|
||||
};
|
||||
|
||||
export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents) => {
|
||||
return validationEvents && validationEvents[event];
|
||||
};
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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]: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
90
public/app/features/teams/TeamMemberRow.test.tsx
Normal file
90
public/app/features/teams/TeamMemberRow.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
106
public/app/features/teams/TeamMemberRow.tsx
Normal file
106
public/app/features/teams/TeamMemberRow.tsx
Normal 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);
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -69,6 +69,13 @@ exports[`Render should render component 1`] = `
|
||||
<th>
|
||||
Email
|
||||
</th>
|
||||
<Component
|
||||
featureToggle={false}
|
||||
>
|
||||
<th>
|
||||
Permission
|
||||
</th>
|
||||
</Component>
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
@@ -153,217 +160,13 @@ exports[`Render should render team members 1`] = `
|
||||
<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
|
||||
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}
|
||||
featureToggle={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>
|
||||
<th>
|
||||
Permission
|
||||
</th>
|
||||
</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 {
|
||||
@@ -374,211 +177,106 @@ exports[`Render should render team members when sync enabled 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/"
|
||||
member={
|
||||
Object {
|
||||
"avatarUrl": "some/url/",
|
||||
"email": "test@test.com",
|
||||
"labels": Array [
|
||||
"label 1",
|
||||
"label 2",
|
||||
],
|
||||
"login": "testUser-1",
|
||||
"permission": 0,
|
||||
"teamId": 1,
|
||||
"userId": 1,
|
||||
}
|
||||
}
|
||||
signedInUserIsTeamAdmin={true}
|
||||
syncEnabled={false}
|
||||
/>
|
||||
</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
|
||||
<Connect(TeamMemberRow)
|
||||
editorsCanAdmin={false}
|
||||
key="2"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
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}
|
||||
/>
|
||||
</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
|
||||
<Connect(TeamMemberRow)
|
||||
editorsCanAdmin={false}
|
||||
key="3"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
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}
|
||||
/>
|
||||
</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
|
||||
<Connect(TeamMemberRow)
|
||||
editorsCanAdmin={false}
|
||||
key="4"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
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}
|
||||
/>
|
||||
</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
|
||||
<Connect(TeamMemberRow)
|
||||
editorsCanAdmin={false}
|
||||
key="5"
|
||||
>
|
||||
<td
|
||||
className="width-4 text-center"
|
||||
>
|
||||
<img
|
||||
className="filter-table__avatar"
|
||||
src="some/url/"
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
|
||||
orientation={options.orientation}
|
||||
thresholds={options.thresholds}
|
||||
theme={config.theme}
|
||||
displayMode={options.displayMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -31,8 +32,7 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { height, width, options, data } = this.props;
|
||||
const { orientation } = options;
|
||||
const { height, width, options, data, renderCounter } = this.props;
|
||||
return (
|
||||
<ProcessedValuesRepeater
|
||||
getProcessedValues={this.getProcessedValues}
|
||||
@@ -40,7 +40,8 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
|
||||
width={width}
|
||||
height={height}
|
||||
source={data}
|
||||
orientation={orientation}
|
||||
renderCounter={renderCounter}
|
||||
orientation={options.orientation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ThresholdsEditor, ValueMappingsEditor, PanelOptionsGrid, PanelOptionsGr
|
||||
|
||||
// Types
|
||||
import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui';
|
||||
import { BarGaugeOptions, orientationOptions } from './types';
|
||||
import { BarGaugeOptions, orientationOptions, displayModes } from './types';
|
||||
import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
|
||||
import { SingleStatValueOptions } from '../singlestat2/types';
|
||||
|
||||
@@ -32,6 +32,7 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
|
||||
onMinValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, minValue: target.value });
|
||||
onMaxValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, maxValue: target.value });
|
||||
onOrientationChange = ({ value }) => this.props.onOptionsChange({ ...this.props.options, orientation: value });
|
||||
onDisplayModeChange = ({ value }) => this.props.onOptionsChange({ ...this.props.options, displayMode: value });
|
||||
|
||||
render() {
|
||||
const { options } = this.props;
|
||||
@@ -53,6 +54,16 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
|
||||
value={orientationOptions.find(item => item.value === options.orientation)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<FormLabel width={8}>Display Mode</FormLabel>
|
||||
<Select
|
||||
width={12}
|
||||
options={displayModes}
|
||||
defaultValue={displayModes[0]}
|
||||
onChange={this.onDisplayModeChange}
|
||||
value={displayModes.find(item => item.value === options.displayMode)}
|
||||
/>
|
||||
</div>
|
||||
</PanelOptionsGroup>
|
||||
<ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
|
||||
</PanelOptionsGrid>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { VizOrientation, SelectOptionItem } from '@grafana/ui';
|
||||
|
||||
import { SingleStatBaseOptions } from '../singlestat2/types';
|
||||
|
||||
export interface BarGaugeOptions extends SingleStatBaseOptions {
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
displayMode: 'basic' | 'lcd' | 'gradient';
|
||||
}
|
||||
|
||||
export const displayModes: SelectOptionItem[] = [
|
||||
{ value: 'gradient', label: 'Gradient' },
|
||||
{ value: 'lcd', label: 'Retro LCD' },
|
||||
{ value: 'basic', label: 'Basic' },
|
||||
];
|
||||
|
||||
export const orientationOptions: SelectOptionItem[] = [
|
||||
{ value: VizOrientation.Horizontal, label: 'Horizontal' },
|
||||
{ value: VizOrientation.Vertical, label: 'Vertical' },
|
||||
];
|
||||
|
||||
export interface BarGaugeOptions extends SingleStatBaseOptions {
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
export const defaults: BarGaugeOptions = {
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
displayMode: 'lcd',
|
||||
orientation: VizOrientation.Horizontal,
|
||||
valueOptions: {
|
||||
unit: 'none',
|
||||
|
||||
@@ -37,8 +37,7 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { height, width, options, data } = this.props;
|
||||
const { orientation } = options;
|
||||
const { height, width, options, data, renderCounter } = this.props;
|
||||
return (
|
||||
<ProcessedValuesRepeater
|
||||
getProcessedValues={this.getProcessedValues}
|
||||
@@ -46,7 +45,8 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
|
||||
width={width}
|
||||
height={height}
|
||||
source={data}
|
||||
orientation={orientation}
|
||||
renderCounter={renderCounter}
|
||||
orientation={options.orientation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface Props<T> {
|
||||
height: number;
|
||||
orientation: VizOrientation;
|
||||
source: any; // If this changes, the values will be processed
|
||||
processFlag?: boolean; // change to force processing
|
||||
renderCounter: number; // change to force processing
|
||||
|
||||
getProcessedValues: () => T[];
|
||||
renderValue: (value: T, width: number, height: number) => JSX.Element;
|
||||
@@ -30,8 +30,8 @@ export class ProcessedValuesRepeater<T> extends PureComponent<Props<T>, State<T>
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props<T>) {
|
||||
const { processFlag, source } = this.props;
|
||||
if (processFlag !== prevProps.processFlag || source !== prevProps.source) {
|
||||
const { renderCounter, source } = this.props;
|
||||
if (renderCounter !== prevProps.renderCounter || source !== prevProps.source) {
|
||||
this.setState({ values: this.props.getProcessedValues() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,7 @@ export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>
|
||||
};
|
||||
|
||||
render() {
|
||||
const { height, width, options, data } = this.props;
|
||||
const { orientation } = options;
|
||||
const { height, width, options, data, renderCounter } = this.props;
|
||||
return (
|
||||
<ProcessedValuesRepeater
|
||||
getProcessedValues={this.getProcessedValues}
|
||||
@@ -65,7 +64,8 @@ export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>
|
||||
width={width}
|
||||
height={height}
|
||||
source={data}
|
||||
orientation={orientation}
|
||||
renderCounter={renderCounter}
|
||||
orientation={options.orientation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
.when('/org/teams', {
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
roles: () => ['Editor', 'Admin'],
|
||||
roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),
|
||||
component: () => TeamList,
|
||||
},
|
||||
})
|
||||
@@ -207,7 +207,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
.when('/org/teams/edit/:id/:page?', {
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
roles: () => ['Admin'],
|
||||
roles: () => (config.editorsCanAdmin ? [] : ['Admin']),
|
||||
component: () => TeamPages,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -98,3 +98,23 @@ export const dashboardPermissionLevels: DashboardPermissionInfo[] = [
|
||||
description: 'Can add/remove permissions and can add, edit and delete dashboards.',
|
||||
},
|
||||
];
|
||||
|
||||
export enum TeamPermissionLevel {
|
||||
Member = 0,
|
||||
Admin = 4,
|
||||
}
|
||||
|
||||
export interface TeamPermissionInfo {
|
||||
value: TeamPermissionLevel;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const teamsPermissionLevels: TeamPermissionInfo[] = [
|
||||
{ value: TeamPermissionLevel.Member, label: 'Member', description: 'Is team member' },
|
||||
{
|
||||
value: TeamPermissionLevel.Admin,
|
||||
label: 'Admin',
|
||||
description: 'Can add/remove permissions, members and delete team.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,6 +12,5 @@ export * from './plugins';
|
||||
export * from './organization';
|
||||
export * from './appNotifications';
|
||||
export * from './search';
|
||||
export * from './form';
|
||||
export * from './explore';
|
||||
export * from './store';
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { TeamPermissionLevel } from './acl';
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
email: string;
|
||||
memberCount: number;
|
||||
permission: TeamPermissionLevel;
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
@@ -13,6 +16,7 @@ export interface TeamMember {
|
||||
email: string;
|
||||
login: string;
|
||||
labels: string[];
|
||||
permission: number;
|
||||
}
|
||||
|
||||
export interface TeamGroup {
|
||||
|
||||
@@ -19,3 +19,9 @@ td.admin-settings-key {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-list-table {
|
||||
.team-permissions {
|
||||
padding-right: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import config from 'app/core/config';
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
import { angularMocks, sinon } from '../lib/common';
|
||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
import { PanelPlugin } from 'app/types';
|
||||
|
||||
export function ControllerTestContext(this: any) {
|
||||
const self = this;
|
||||
@@ -62,7 +63,7 @@ export function ControllerTestContext(this: any) {
|
||||
$rootScope.colors.push('#' + i);
|
||||
}
|
||||
|
||||
config.panels['test'] = { info: {} };
|
||||
config.panels['test'] = { info: {} } as PanelPlugin;
|
||||
self.ctrl = $controller(
|
||||
Ctrl,
|
||||
{ $scope: self.scope },
|
||||
|
||||
114
scripts/build/ci-build/Dockerfile
Normal file
114
scripts/build/ci-build/Dockerfile
Normal file
@@ -0,0 +1,114 @@
|
||||
FROM ubuntu:14.04 as toolchain
|
||||
|
||||
ENV OSX_SDK_URL=https://s3.dockerproject.org/darwin/v2/ \
|
||||
OSX_SDK=MacOSX10.10.sdk \
|
||||
OSX_MIN=10.10 \
|
||||
CTNG=1.23.0
|
||||
|
||||
# FIRST PART
|
||||
# build osx64 toolchain (stripped of man documentation)
|
||||
# the toolchain produced is not self contained, it needs clang at runtime
|
||||
#
|
||||
# SECOND PART
|
||||
# build gcc (no g++) centos6-x64 toolchain
|
||||
# doc: https://crosstool-ng.github.io/docs/
|
||||
# apt-get should be all dep to build toolchain
|
||||
# sed and 1st echo are for convenience to get the toolchain in /tmp/x86_64-centos6-linux-gnu
|
||||
# other echo are to enable build by root (crosstool-NG refuse to do that by default)
|
||||
# the last 2 rm are just to save some time and space writing docker layers
|
||||
#
|
||||
# THIRD PART
|
||||
# build fpm and creates a set of deb from gem
|
||||
# ruby2.0 depends on ruby1.9.3 which is install as default ruby
|
||||
# rm/ln are here to change that
|
||||
# created deb depends on rubygem-json but json gem is not build
|
||||
# so do by hand
|
||||
|
||||
|
||||
# might wanna make sure osx cross and the other tarball as well as the packages ends up somewhere other than tmp
|
||||
# might also wanna put them as their own layer to not have to unpack them every time?
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
clang-3.8 patch libxml2-dev \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
make \
|
||||
xz-utils && \
|
||||
git clone https://github.com/tpoechtrager/osxcross.git /tmp/osxcross && \
|
||||
curl -L ${OSX_SDK_URL}/${OSX_SDK}.tar.xz -o /tmp/osxcross/tarballs/${OSX_SDK}.tar.xz && \
|
||||
ln -s /usr/bin/clang-3.8 /usr/bin/clang && \
|
||||
ln -s /usr/bin/clang++-3.8 /usr/bin/clang++ && \
|
||||
ln -s /usr/bin/llvm-dsymutil-3.8 /usr/bin/dsymutil && \
|
||||
UNATTENDED=yes OSX_VERSION_MIN=${OSX_MIN} /tmp/osxcross/build.sh && \
|
||||
rm -rf /tmp/osxcross/target/SDK/${OSX_SDK}/usr/share && \
|
||||
cd /tmp && \
|
||||
tar cfJ osxcross.tar.xz osxcross/target && \
|
||||
rm -rf /tmp/osxcross && \
|
||||
apt-get install -y \
|
||||
bison curl flex gawk gcc g++ gperf help2man libncurses5-dev make patch python-dev texinfo xz-utils && \
|
||||
curl -L http://crosstool-ng.org/download/crosstool-ng/crosstool-ng-${CTNG}.tar.xz \
|
||||
| tar -xJ -C /tmp/ && \
|
||||
cd /tmp/crosstool-ng-${CTNG} && \
|
||||
./configure --enable-local && \
|
||||
make && \
|
||||
./ct-ng x86_64-centos6-linux-gnu && \
|
||||
sed -i '/CT_PREFIX_DIR=/d' .config && \
|
||||
echo 'CT_PREFIX_DIR="/tmp/${CT_HOST:+HOST-${CT_HOST}/}${CT_TARGET}"' >> .config && \
|
||||
echo 'CT_EXPERIMENTAL=y' >> .config && \
|
||||
echo 'CT_ALLOW_BUILD_AS_ROOT=y' >> .config && \
|
||||
echo 'CT_ALLOW_BUILD_AS_ROOT_SURE=y' >> .config && \
|
||||
./ct-ng build && \
|
||||
cd /tmp && \
|
||||
rm /tmp/x86_64-centos6-linux-gnu/build.log.bz2 && \
|
||||
tar cfJ x86_64-centos6-linux-gnu.tar.xz x86_64-centos6-linux-gnu/ && \
|
||||
rm -rf /tmp/x86_64-centos6-linux-gnu/ && \
|
||||
rm -rf /tmp/crosstool-ng-${CTNG}
|
||||
|
||||
# base image to crossbuild grafana
|
||||
FROM ubuntu:14.04
|
||||
|
||||
ENV GOVERSION=1.11.5 \
|
||||
PATH=/usr/local/go/bin:$PATH \
|
||||
GOPATH=/go \
|
||||
NODEVERSION=10.14.2
|
||||
|
||||
COPY --from=toolchain /tmp/x86_64-centos6-linux-gnu.tar.xz /tmp/
|
||||
COPY --from=toolchain /tmp/osxcross.tar.xz /tmp/
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
clang-3.8 gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-mingw-w64-x86-64 \
|
||||
apt-transport-https \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libfontconfig1 \
|
||||
gcc \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
rpm \
|
||||
xz-utils \
|
||||
expect \
|
||||
gnupg2 \
|
||||
unzip && \
|
||||
ln -s /usr/bin/clang-3.8 /usr/bin/clang && \
|
||||
ln -s /usr/bin/clang++-3.8 /usr/bin/clang++ && \
|
||||
ln -s /usr/bin/llvm-dsymutil-3.8 /usr/bin/dsymutil && \
|
||||
curl -L https://nodejs.org/dist/v${NODEVERSION}/node-v${NODEVERSION}-linux-x64.tar.xz \
|
||||
| tar -xJ --strip-components=1 -C /usr/local && \
|
||||
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
|
||||
echo "deb [arch=amd64] https://dl.yarnpkg.com/debian/ stable main" \
|
||||
| tee /etc/apt/sources.list.d/yarn.list && \
|
||||
apt-get update && apt-get install --no-install-recommends yarn && \
|
||||
curl -L https://storage.googleapis.com/golang/go${GOVERSION}.linux-amd64.tar.gz \
|
||||
| tar -xz -C /usr/local
|
||||
|
||||
RUN apt-get install -y \
|
||||
gcc libc-dev make && \
|
||||
gpg2 --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB && \
|
||||
curl -sSL https://get.rvm.io | bash -s stable && \
|
||||
/bin/bash -l -c "rvm requirements && rvm install 2.2 && gem install -N fpm"
|
||||
|
||||
ADD ./bootstrap.sh /tmp/bootstrap.sh
|
||||
54
scripts/build/ci-build/Makefile
Normal file
54
scripts/build/ci-build/Makefile
Normal file
@@ -0,0 +1,54 @@
|
||||
VERSION="dev"
|
||||
TAG="grafana/build-container"
|
||||
USER_ID=$(shell id -u)
|
||||
GROUP_ID=$(shell id -g)
|
||||
|
||||
all: build deploy
|
||||
|
||||
build:
|
||||
docker build -t "${TAG}:${VERSION}" .
|
||||
|
||||
deploy:
|
||||
docker push "${TAG}:${VERSION}"
|
||||
|
||||
run:
|
||||
docker run -ti \
|
||||
-e "CIRCLE_BRANCH=local" \
|
||||
-e "CIRCLE_BUILD_NUM=472" \
|
||||
${TAG}:${VERSION} \
|
||||
bash
|
||||
|
||||
run-with-local-source-live:
|
||||
docker run -d \
|
||||
-e "CIRCLE_BRANCH=local" \
|
||||
-e "CIRCLE_BUILD_NUM=472" \
|
||||
-w "/go/src/github.com/grafana/grafana" \
|
||||
--name grafana-build \
|
||||
-v "${GOPATH}/src/github.com/grafana/grafana:/go/src/github.com/grafana/grafana" \
|
||||
${TAG}:${VERSION} \
|
||||
bash -c "/tmp/bootstrap.sh; mkdir /.cache; chown "${USER_ID}:${GROUP_ID}" /.cache; tail -f /dev/null"
|
||||
docker exec -ti --user "${USER_ID}:${GROUP_ID}" grafana-build bash
|
||||
|
||||
run-with-local-source-copy:
|
||||
docker run -d \
|
||||
-e "CIRCLE_BRANCH=local" \
|
||||
-e "CIRCLE_BUILD_NUM=472" \
|
||||
-w "/go/src/github.com/grafana/grafana" \
|
||||
--name grafana-build \
|
||||
${TAG}:${VERSION} \
|
||||
bash -c "/tmp/bootstrap.sh; tail -f /dev/null"
|
||||
docker cp "${GOPATH}/src/github.com/grafana/grafana" grafana-build:/go/src/github.com/grafana/
|
||||
docker exec -ti grafana-build bash
|
||||
|
||||
update-source:
|
||||
docker cp "${GOPATH}/src/github.com/grafana/grafana" grafana-build:/go/src/github.com/grafana/
|
||||
|
||||
attach:
|
||||
docker exec -ti grafana-build bash
|
||||
|
||||
attach-live:
|
||||
docker exec -ti --user "${USER_ID}:${GROUP_ID}" grafana-build bash
|
||||
|
||||
stop:
|
||||
docker kill grafana-build
|
||||
docker rm grafana-build
|
||||
20
scripts/build/ci-build/README.md
Normal file
20
scripts/build/ci-build/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# grafana-build-container
|
||||
Grafana build container
|
||||
|
||||
## Description
|
||||
|
||||
This is a container for cross-platform builds of Grafana. You can run it locally using the Makefile.
|
||||
|
||||
## Makefile targets
|
||||
|
||||
* `make run-with-local-source-copy`
|
||||
- Starts the container locally and copies your local sources into the container
|
||||
* `make run-with-local-source-live`
|
||||
- Starts the container (as your user) locally and maps your Grafana project dir into the container
|
||||
* `make update-source`
|
||||
- Updates the sources in the container from your local sources
|
||||
* `make stop`
|
||||
- Kills the container
|
||||
* `make attach`
|
||||
- Opens bash within the running container
|
||||
|
||||
5
scripts/build/ci-build/bootstrap.sh
Executable file
5
scripts/build/ci-build/bootstrap.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd /tmp
|
||||
tar xfJ x86_64-centos6-linux-gnu.tar.xz
|
||||
tar xfJ osxcross.tar.xz
|
||||
7
scripts/build/ci-build/build-deploy.sh
Executable file
7
scripts/build/ci-build/build-deploy.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
_version="1.2.3"
|
||||
_tag="grafana/build-container:${_version}"
|
||||
|
||||
docker build -t $_tag .
|
||||
docker push $_tag
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user