mirror of
https://github.com/grafana/grafana.git
synced 2024-11-26 02:40:26 -06:00
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:
parent
96099636dc
commit
5c89a8451e
@ -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={() => {}} />;
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user