diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeaderSettings.story.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeaderSettings.story.tsx new file mode 100644 index 00000000000..f9035ffedc7 --- /dev/null +++ b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeaderSettings.story.tsx @@ -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 = { + 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 {}} />; +}; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.test.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.test.tsx new file mode 100644 index 00000000000..1f091688a05 --- /dev/null +++ b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.test.tsx @@ -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(); +}; + +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(); + }); +}); diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx new file mode 100644 index 00000000000..476787ce38c --- /dev/null +++ b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx @@ -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; + 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 = ({ header, onBlur, onChange, onRemove, onReset }) => { + const styles = getCustomHeaderRowStyles(); + return ( +
+ onChange({ ...header, name: e.target.value })} + onBlur={onBlur} + /> + onReset(header.id)} + onChange={e => onChange({ ...header, value: e.target.value })} + onBlur={onBlur} + /> + +
+ ); +}; + +CustomHeaderRow.displayName = 'CustomHeaderRow'; + +export class CustomHeadersSettings extends PureComponent { + 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 ( +
+
+
Custom HTTP Headers
+
+
+ {headers.map((header, i) => ( + { + this.onHeaderChange(i, h); + }} + onBlur={this.updateSettings} + onRemove={this.onHeaderRemove} + onReset={this.onHeaderReset} + /> + ))} +
+
+ +
+
+ ); + } +} + +export default CustomHeadersSettings; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx index f70a4526d7b..27e02d65065 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx @@ -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 = props => { {(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && ( )} + + ); diff --git a/packages/grafana-ui/src/components/SecretFormFied/SecretFormField.tsx b/packages/grafana-ui/src/components/SecretFormFied/SecretFormField.tsx index b16308e1799..0c46aa8e633 100644 --- a/packages/grafana-ui/src/components/SecretFormFied/SecretFormField.tsx +++ b/packages/grafana-ui/src/components/SecretFormFied/SecretFormField.tsx @@ -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 { +interface Props extends Omit, '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) => void; isConfigured: boolean; label?: string; @@ -15,12 +16,18 @@ interface Props extends InputHTMLAttributes { 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 = ({ - label, + label = 'Password', labelWidth, - inputWidth, + inputWidth = 12, onReset, isConfigured, - placeholder, + placeholder = 'Password', ...inputProps }: Props) => { + const styles = getSecretFormFieldStyles(); return ( = ({ <> - @@ -67,5 +79,4 @@ export const SecretFormField: FunctionComponent = ({ ); }; -SecretFormField.defaultProps = defaultProps; SecretFormField.displayName = 'SecretFormField'; diff --git a/public/app/features/datasources/partials/http_settings.html b/public/app/features/datasources/partials/http_settings.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/public/app/plugins/datasource/influxdb/components/__snapshots__/ConfigEditor.test.tsx.snap b/public/app/plugins/datasource/influxdb/components/__snapshots__/ConfigEditor.test.tsx.snap index 3a27357ae72..bedce4b6188 100644 --- a/public/app/plugins/datasource/influxdb/components/__snapshots__/ConfigEditor.test.tsx.snap +++ b/public/app/plugins/datasource/influxdb/components/__snapshots__/ConfigEditor.test.tsx.snap @@ -97,7 +97,6 @@ exports[`Render should disable basic auth password input 1`] = ` labelWidth={10} onChange={[Function]} onReset={[Function]} - placeholder="Password" value="" /> @@ -315,7 +314,6 @@ exports[`Render should hide basic auth fields when switch off 1`] = ` labelWidth={10} onChange={[Function]} onReset={[Function]} - placeholder="Password" value="" /> @@ -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="" /> @@ -751,7 +748,6 @@ exports[`Render should render component 1`] = ` labelWidth={10} onChange={[Function]} onReset={[Function]} - placeholder="Password" value="" />