DatasourceEditor: Add UI to edit custom HTTP headers (#17846)

* DatasourceEditor: Add UI to edit custom HTTP headers

Support for custom headers was added in #11643 but was missing in the UI.

Fixes #12779

* Review

* Layout updates

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Adrien F 2020-02-04 09:25:10 +01:00 committed by GitHub
parent 96099636dc
commit 5c89a8451e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 374 additions and 17 deletions

View File

@ -0,0 +1,46 @@
import React from 'react';
import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
import { CustomHeadersSettings } from './CustomHeadersSettings';
import { DataSourceSettings } from '@grafana/data';
export default {
title: 'UI/DataSource/CustomHeadersSettings',
component: CustomHeadersSettings,
decorators: [withCenteredStory, withHorizontallyCenteredStory],
};
export const simple = () => {
const dataSourceConfig: DataSourceSettings<any, any> = {
id: 4,
orgId: 1,
name: 'gdev-influxdb',
type: 'influxdb',
typeLogoUrl: '',
access: 'direct',
url: 'http://localhost:8086',
password: '',
user: 'grafana',
database: 'site',
basicAuth: false,
basicAuthUser: '',
basicAuthPassword: '',
withCredentials: false,
isDefault: false,
jsonData: {
timeInterval: '15s',
httpMode: 'GET',
keepCookies: ['cookie1', 'cookie2'],
httpHeaderName1: 'X-Custom-Header',
},
secureJsonData: {
password: true,
httpHeaderValue1: 'X-Custom-Header',
},
secureJsonFields: {
httpHeaderValue1: true,
},
readOnly: true,
};
return <CustomHeadersSettings dataSourceConfig={dataSourceConfig} onChange={() => {}} />;
};

View File

@ -0,0 +1,82 @@
import React from 'react';
import { mount } from 'enzyme';
import { CustomHeadersSettings, Props } from './CustomHeadersSettings';
const setup = (propOverrides?: object) => {
const props: Props = {
dataSourceConfig: {
id: 4,
orgId: 1,
name: 'gdev-influxdb',
type: 'influxdb',
typeLogoUrl: '',
access: 'direct',
url: 'http://localhost:8086',
password: '',
user: 'grafana',
database: 'site',
basicAuth: false,
basicAuthUser: '',
basicAuthPassword: '',
withCredentials: false,
isDefault: false,
jsonData: {
timeInterval: '15s',
httpMode: 'GET',
keepCookies: ['cookie1', 'cookie2'],
},
secureJsonData: {
password: true,
},
readOnly: true,
},
onChange: jest.fn(),
...propOverrides,
};
return mount(<CustomHeadersSettings {...props} />);
};
describe('Render', () => {
it('should add a new header', () => {
const wrapper = setup();
const addButton = wrapper.find('Button').at(0);
addButton.simulate('click', { preventDefault: () => {} });
expect(wrapper.find('FormField').exists()).toBeTruthy();
expect(wrapper.find('SecretFormField').exists()).toBeTruthy();
});
it('should remove a header', () => {
const wrapper = setup({
dataSourceConfig: {
jsonData: {
httpHeaderName1: 'X-Custom-Header',
},
secureJsonFields: {
httpHeaderValue1: true,
},
},
});
const removeButton = wrapper.find('Button').find({ variant: 'transparent' });
removeButton.simulate('click', { preventDefault: () => {} });
expect(wrapper.find('FormField').exists()).toBeFalsy();
expect(wrapper.find('SecretFormField').exists()).toBeFalsy();
});
it('should reset a header', () => {
const wrapper = setup({
dataSourceConfig: {
jsonData: {
httpHeaderName1: 'X-Custom-Header',
},
secureJsonFields: {
httpHeaderValue1: true,
},
},
});
const resetButton = wrapper.find('button').at(0);
resetButton.simulate('click', { preventDefault: () => {} });
const { isConfigured } = wrapper.find('SecretFormField').props() as any;
expect(isConfigured).toBeFalsy();
});
});

View File

@ -0,0 +1,219 @@
import React, { PureComponent } from 'react';
import { css } from 'emotion';
import uniqueId from 'lodash/uniqueId';
import { DataSourceSettings } from '@grafana/data';
import { Button } from '../Button/Button';
import { FormField } from '../FormField/FormField';
import { SecretFormField } from '../SecretFormFied/SecretFormField';
import { stylesFactory } from '../../themes';
export interface CustomHeader {
id: string;
name: string;
value: string;
configured: boolean;
}
export type CustomHeaders = CustomHeader[];
export interface Props {
dataSourceConfig: DataSourceSettings<any, any>;
onChange: (config: DataSourceSettings) => void;
}
export interface State {
headers: CustomHeaders;
}
interface CustomHeaderRowProps {
header: CustomHeader;
onReset: (id: string) => void;
onRemove: (id: string) => void;
onChange: (value: CustomHeader) => void;
onBlur: () => void;
}
const getCustomHeaderRowStyles = stylesFactory(() => {
return {
layout: css`
display: flex;
align-items: center;
margin-bottom: 4px;
> * {
margin-left: 4px;
margin-bottom: 0;
height: 100%;
&:first-child,
&:last-child {
margin-left: 0;
}
}
`,
};
});
const CustomHeaderRow: React.FC<CustomHeaderRowProps> = ({ header, onBlur, onChange, onRemove, onReset }) => {
const styles = getCustomHeaderRowStyles();
return (
<div className={styles.layout}>
<FormField
label="Header"
name="name"
placeholder="X-Custom-Header"
labelWidth={5}
value={header.name || ''}
onChange={e => onChange({ ...header, name: e.target.value })}
onBlur={onBlur}
/>
<SecretFormField
label="Value"
name="value"
isConfigured={header.configured}
value={header.value}
labelWidth={5}
inputWidth={header.configured ? 11 : 12}
placeholder="Header Value"
onReset={() => onReset(header.id)}
onChange={e => onChange({ ...header, value: e.target.value })}
onBlur={onBlur}
/>
<Button variant="transparent" size="xs" onClick={_e => onRemove(header.id)}>
<i className="fa fa-trash" />
</Button>
</div>
);
};
CustomHeaderRow.displayName = 'CustomHeaderRow';
export class CustomHeadersSettings extends PureComponent<Props, State> {
state: State = {
headers: [],
};
constructor(props: Props) {
super(props);
const { jsonData, secureJsonData, secureJsonFields } = this.props.dataSourceConfig;
this.state = {
headers: Object.keys(jsonData)
.sort()
.filter(key => key.startsWith('httpHeaderName'))
.map((key, index) => {
return {
id: uniqueId(),
name: jsonData[key],
value: secureJsonData !== undefined ? secureJsonData[key] : '',
configured: (secureJsonFields && secureJsonFields[`httpHeaderValue${index + 1}`]) || false,
};
}),
};
}
updateSettings = () => {
const { headers } = this.state;
const { jsonData } = this.props.dataSourceConfig;
const secureJsonData = this.props.dataSourceConfig.secureJsonData || {};
for (const [index, header] of headers.entries()) {
jsonData[`httpHeaderName${index + 1}`] = header.name;
if (!header.configured) {
secureJsonData[`httpHeaderValue${index + 1}`] = header.value;
}
Object.keys(jsonData)
.filter(
key =>
key.startsWith('httpHeaderName') && parseInt(key.substring('httpHeaderName'.length), 10) > headers.length
)
.forEach(key => {
delete jsonData[key];
});
}
this.props.onChange({
...this.props.dataSourceConfig,
jsonData,
secureJsonData,
});
};
onHeaderAdd = () => {
this.setState(prevState => {
return { headers: [...prevState.headers, { id: uniqueId(), name: '', value: '', configured: false }] };
}, this.updateSettings);
};
onHeaderChange = (headerIndex: number, value: CustomHeader) => {
this.setState(({ headers }) => {
return {
headers: headers.map((item, index) => {
if (headerIndex !== index) {
return item;
}
return { ...value };
}),
};
});
};
onHeaderReset = (headerId: string) => {
this.setState(({ headers }) => {
return {
headers: headers.map((h, i) => {
if (h.id !== headerId) {
return h;
}
return {
...h,
value: '',
configured: false,
};
}),
};
});
};
onHeaderRemove = (headerId: string) => {
this.setState(
({ headers }) => ({
headers: headers.filter(h => h.id !== headerId),
}),
this.updateSettings
);
};
render() {
const { headers } = this.state;
return (
<div className={'gf-form-group'}>
<div className="gf-form">
<h6>Custom HTTP Headers</h6>
</div>
<div>
{headers.map((header, i) => (
<CustomHeaderRow
key={header.id}
header={header}
onChange={h => {
this.onHeaderChange(i, h);
}}
onBlur={this.updateSettings}
onRemove={this.onHeaderRemove}
onReset={this.onHeaderReset}
/>
))}
</div>
<div className="gf-form">
<Button
variant="inverse"
size="xs"
onClick={e => {
this.onHeaderAdd();
}}
>
Add Header
</Button>
</div>
</div>
);
}
}
export default CustomHeadersSettings;

View File

@ -7,6 +7,7 @@ import { HttpProxySettings } from './HttpProxySettings';
import { TLSAuthSettings } from './TLSAuthSettings';
import { DataSourceSettings } from '@grafana/data';
import { HttpSettingsProps } from './types';
import { CustomHeadersSettings } from './CustomHeadersSettings';
import { Select } from '../Select/Select';
import { Input } from '../Input/Input';
import { FormField } from '../FormField/FormField';
@ -207,6 +208,8 @@ export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = props => {
{(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && (
<TLSAuthSettings dataSourceConfig={dataSourceConfig} onChange={onChange} />
)}
<CustomHeadersSettings dataSourceConfig={dataSourceConfig} onChange={onChange} />
</>
</div>
);

View File

@ -1,11 +1,12 @@
import omit from 'lodash/omit';
import React, { InputHTMLAttributes, FunctionComponent } from 'react';
import { FormField } from '../FormField/FormField';
import { css, cx } from 'emotion';
interface Props extends InputHTMLAttributes<HTMLInputElement> {
interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onReset'> {
// Function to use when reset is clicked. Means you have to reset the input value yourself as this is uncontrolled
// component (or do something else if required).
onReset: () => void;
onReset: (event: React.SyntheticEvent<HTMLButtonElement>) => void;
isConfigured: boolean;
label?: string;
@ -15,12 +16,18 @@ interface Props extends InputHTMLAttributes<HTMLInputElement> {
placeholder?: string;
}
const defaultProps = {
inputWidth: 12,
placeholder: 'Password',
label: 'Password',
const getSecretFormFieldStyles = () => {
return {
noRadiusInput: css`
border-bottom-right-radius: 0 !important;
border-top-right-radius: 0 !important;
`,
noRadiusButton: css`
border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important;
`,
};
};
/**
* Form field that has 2 states configured and not configured. If configured it will not show its contents and adds
* a reset button that will clear the input and makes it accessible. In non configured state it behaves like normal
@ -28,14 +35,15 @@ const defaultProps = {
* to the user (like datasource passwords).
*/
export const SecretFormField: FunctionComponent<Props> = ({
label,
label = 'Password',
labelWidth,
inputWidth,
inputWidth = 12,
onReset,
isConfigured,
placeholder,
placeholder = 'Password',
...inputProps
}: Props) => {
const styles = getSecretFormFieldStyles();
return (
<FormField
label={label!}
@ -45,12 +53,16 @@ export const SecretFormField: FunctionComponent<Props> = ({
<>
<input
type="text"
className={`gf-form-input width-${inputWidth! - 2}`}
className={cx(`gf-form-input width-${inputWidth! - 2}`, styles.noRadiusInput)}
disabled={true}
value="configured"
{...omit(inputProps, 'value')}
/>
<button className="btn btn-secondary gf-form-btn" onClick={onReset}>
<button
className={cx('btn btn-secondary gf-form-btn', styles.noRadiusButton)}
onClick={onReset}
style={{ height: '100%' }}
>
reset
</button>
</>
@ -67,5 +79,4 @@ export const SecretFormField: FunctionComponent<Props> = ({
);
};
SecretFormField.defaultProps = defaultProps;
SecretFormField.displayName = 'SecretFormField';

View File

@ -97,7 +97,6 @@ exports[`Render should disable basic auth password input 1`] = `
labelWidth={10}
onChange={[Function]}
onReset={[Function]}
placeholder="Password"
value=""
/>
</div>
@ -315,7 +314,6 @@ exports[`Render should hide basic auth fields when switch off 1`] = `
labelWidth={10}
onChange={[Function]}
onReset={[Function]}
placeholder="Password"
value=""
/>
</div>
@ -533,7 +531,6 @@ exports[`Render should hide white listed cookies input when browser access chose
labelWidth={10}
onChange={[Function]}
onReset={[Function]}
placeholder="Password"
value=""
/>
</div>
@ -751,7 +748,6 @@ exports[`Render should render component 1`] = `
labelWidth={10}
onChange={[Function]}
onReset={[Function]}
placeholder="Password"
value=""
/>
</div>