Commit 0091b86e by Torkel Ödegaard Committed by GitHub

Merge pull request #16078 from grafana/secret-input-field-component

Secret input field component
parents abd89484 a26dc64e
...@@ -2,7 +2,7 @@ import React from 'react'; ...@@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { FormField, Props } from './FormField'; import { FormField, Props } from './FormField';
const setup = (propOverrides?: object) => { const setup = (propOverrides?: Partial<Props>) => {
const props: Props = { const props: Props = {
label: 'Test', label: 'Test',
labelWidth: 11, labelWidth: 11,
...@@ -15,10 +15,23 @@ const setup = (propOverrides?: object) => { ...@@ -15,10 +15,23 @@ const setup = (propOverrides?: object) => {
return shallow(<FormField {...props} />); return shallow(<FormField {...props} />);
}; };
describe('Render', () => { describe('FormField', () => {
it('should render component', () => { it('should render component with default inputEl', () => {
const wrapper = setup(); const wrapper = setup();
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
it('should render component with custom inputEl', () => {
const wrapper = setup({
inputEl: (
<>
<span>Input</span>
<button>Ok</button>
</>
),
});
expect(wrapper).toMatchSnapshot();
});
}); });
...@@ -5,6 +5,7 @@ export interface Props extends InputHTMLAttributes<HTMLInputElement> { ...@@ -5,6 +5,7 @@ export interface Props extends InputHTMLAttributes<HTMLInputElement> {
label: string; label: string;
labelWidth?: number; labelWidth?: number;
inputWidth?: number; inputWidth?: number;
inputEl?: React.ReactNode;
} }
const defaultProps = { const defaultProps = {
...@@ -12,14 +13,18 @@ const defaultProps = { ...@@ -12,14 +13,18 @@ const defaultProps = {
inputWidth: 12, inputWidth: 12,
}; };
const FormField: FunctionComponent<Props> = ({ label, labelWidth, inputWidth, ...inputProps }) => { /**
* Default form field including label used in Grafana UI. Default input element is simple <input />. You can also pass
* custom inputEl if required in which case inputWidth and inputProps are ignored.
*/
export const FormField: FunctionComponent<Props> = ({ label, labelWidth, inputWidth, inputEl, ...inputProps }) => {
return ( return (
<div className="form-field"> <div className="form-field">
<FormLabel width={labelWidth}>{label}</FormLabel> <FormLabel width={labelWidth}>{label}</FormLabel>
<input type="text" className={`gf-form-input width-${inputWidth}`} {...inputProps} /> {inputEl || <input type="text" className={`gf-form-input width-${inputWidth}`} {...inputProps} />}
</div> </div>
); );
}; };
FormField.displayName = 'FormField';
FormField.defaultProps = defaultProps; FormField.defaultProps = defaultProps;
export { FormField };
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = ` exports[`FormField should render component with custom inputEl 1`] = `
<div
className="form-field"
>
<Component
width={11}
>
Test
</Component>
<span>
Input
</span>
<button>
Ok
</button>
</div>
`;
exports[`FormField should render component with default inputEl 1`] = `
<div <div
className="form-field" className="form-field"
> >
......
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import { SecretFormField } from './SecretFormField';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
const SecretFormFieldStories = storiesOf('UI/SecretFormField/SecretFormField', module);
SecretFormFieldStories.addDecorator(withCenteredStory);
const getSecretFormFieldKnobs = () => {
return {
isConfigured: boolean('Set configured state', false),
};
};
SecretFormFieldStories.add('default', () => {
const knobs = getSecretFormFieldKnobs();
return (
<UseState initialState="Input value">
{(value, setValue) => (
<SecretFormField
label={'Secret field'}
labelWidth={10}
value={value}
isConfigured={knobs.isConfigured}
onChange={e => setValue(e.currentTarget.value)}
onReset={() => {
action('Value was reset')('');
setValue('');
}}
/>
)}
</UseState>
);
});
import { omit } from 'lodash';
import React, { InputHTMLAttributes, FunctionComponent } from 'react';
import { FormField } from '..';
interface Props extends InputHTMLAttributes<HTMLInputElement> {
// 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;
isConfigured: boolean;
label?: string;
labelWidth?: number;
inputWidth?: number;
// Placeholder of the input field when in non configured state.
placeholder?: string;
}
const defaultProps = {
inputWidth: 12,
placeholder: 'Password',
label: 'Password',
};
/**
* 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
* form field. This is used for passwords or anything that is encrypted on the server and is later returned encrypted
* to the user (like datasource passwords).
*/
export const SecretFormField: FunctionComponent<Props> = ({
label,
labelWidth,
inputWidth,
onReset,
isConfigured,
placeholder,
...inputProps
}: Props) => {
return (
<FormField
label={label!}
labelWidth={labelWidth}
inputEl={
isConfigured ? (
<>
<input
type="text"
className={`gf-form-input width-${inputWidth! - 2}`}
disabled={true}
value="configured"
{...omit(inputProps, 'value')}
/>
<button className="btn btn-secondary gf-form-btn" onClick={onReset}>
reset
</button>
</>
) : (
<input
type="password"
className={`gf-form-input width-${inputWidth}`}
placeholder={placeholder}
{...inputProps}
/>
)
}
/>
);
};
SecretFormField.defaultProps = defaultProps;
SecretFormField.displayName = 'SecretFormField';
...@@ -14,6 +14,7 @@ export { default as resetSelectStyles } from './Select/resetSelectStyles'; ...@@ -14,6 +14,7 @@ export { default as resetSelectStyles } from './Select/resetSelectStyles';
// Forms // Forms
export { FormLabel } from './FormLabel/FormLabel'; export { FormLabel } from './FormLabel/FormLabel';
export { FormField } from './FormField/FormField'; export { FormField } from './FormField/FormField';
export { SecretFormField } from './SecretFormFied/SecretFormField';
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder'; export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker'; export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
......
...@@ -2,7 +2,7 @@ import React from 'react'; ...@@ -2,7 +2,7 @@ import React from 'react';
interface StateHolderProps<T> { interface StateHolderProps<T> {
initialState: T; initialState: T;
children: (currentState: T, updateState: (nextState: T) => void) => JSX.Element; children: (currentState: T, updateState: (nextState: T) => void) => React.ReactNode;
} }
export class UseState<T> extends React.Component<StateHolderProps<T>, { value: T; initialState: T }> { export class UseState<T> extends React.Component<StateHolderProps<T>, { value: T; initialState: T }> {
......
...@@ -9,7 +9,7 @@ import { TagFilter } from './components/TagFilter/TagFilter'; ...@@ -9,7 +9,7 @@ import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu'; import { SideMenu } from './components/sidemenu/SideMenu';
import { MetricSelect } from './components/Select/MetricSelect'; import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList'; import AppNotificationList from './components/AppNotifications/AppNotificationList';
import { ColorPicker, SeriesColorPickerPopoverWithTheme } from '@grafana/ui'; import { ColorPicker, SeriesColorPickerPopoverWithTheme, SecretFormField } from '@grafana/ui';
import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor'; import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor';
export function registerAngularDirectives() { export function registerAngularDirectives() {
...@@ -59,4 +59,11 @@ export function registerAngularDirectives() { ...@@ -59,4 +59,11 @@ export function registerAngularDirectives() {
['datasource', { watchDepth: 'reference' }], ['datasource', { watchDepth: 'reference' }],
['templateSrv', { watchDepth: 'reference' }], ['templateSrv', { watchDepth: 'reference' }],
]); ]);
react2AngularDirective('secretFormField', SecretFormField, [
'value',
'isConfigured',
'inputWidth',
['onReset', { watchDepth: 'reference', wrapApply: true }],
['onChange', { watchDepth: 'reference', wrapApply: true }],
]);
} }
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
// - reactComponent (generic directive for delegating off to React Components) // - reactComponent (generic directive for delegating off to React Components)
// - reactDirective (factory for creating specific directives that correspond to reactComponent directives) // - reactDirective (factory for creating specific directives that correspond to reactComponent directives)
import { kebabCase } from 'lodash';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import angular from 'angular'; import angular from 'angular';
...@@ -155,11 +156,17 @@ function getPropExpression(prop) { ...@@ -155,11 +156,17 @@ function getPropExpression(prop) {
return Array.isArray(prop) ? prop[0] : prop; return Array.isArray(prop) ? prop[0] : prop;
} }
// find the normalized attribute knowing that React props accept any type of capitalization /**
function findAttribute(attrs, propName) { * Finds the normalized attribute knowing that React props accept any type of capitalization and it also handles
const index = Object.keys(attrs).filter(attr => { * kabab case attributes which can be used in case the attribute would also be a standard html attribute and would be
return attr.toLowerCase() === propName.toLowerCase(); * evaluated by the browser as such.
})[0]; * @param attrs All attributes of the component.
* @param propName Name of the prop that react component expects.
*/
function findAttribute(attrs: string, propName: string): string {
const index = Object.keys(attrs).find(attr => {
return attr.toLowerCase() === propName.toLowerCase() || attr.toLowerCase() === kebabCase(propName);
});
return attrs[index]; return attrs[index];
} }
...@@ -274,7 +281,9 @@ const reactDirective = $injector => { ...@@ -274,7 +281,9 @@ const reactDirective = $injector => {
// watch each property name and trigger an update whenever something changes, // watch each property name and trigger an update whenever something changes,
// to update scope.props with new values // to update scope.props with new values
const propExpressions = props.map(prop => { const propExpressions = props.map(prop => {
return Array.isArray(prop) ? [attrs[getPropName(prop)], getPropConfig(prop)] : attrs[prop]; return Array.isArray(prop)
? [findAttribute(attrs, prop[0]), getPropConfig(prop)]
: findAttribute(attrs, prop);
}); });
// If we don't have any props, then our watch statement won't fire. // If we don't have any props, then our watch statement won't fire.
......
import { SyntheticEvent } from 'react';
export class MssqlConfigCtrl { export class MssqlConfigCtrl {
static templateUrl = 'partials/config.html'; static templateUrl = 'partials/config.html';
...@@ -7,4 +9,16 @@ export class MssqlConfigCtrl { ...@@ -7,4 +9,16 @@ export class MssqlConfigCtrl {
constructor($scope) { constructor($scope) {
this.current.jsonData.encrypt = this.current.jsonData.encrypt || 'false'; this.current.jsonData.encrypt = this.current.jsonData.encrypt || 'false';
} }
onPasswordReset = (event: SyntheticEvent<HTMLInputElement>) => {
event.preventDefault();
this.current.secureJsonFields.password = false;
this.current.secureJsonData = this.current.secureJsonData || {};
this.current.secureJsonData.password = '';
};
onPasswordChange = (event: SyntheticEvent<HTMLInputElement>) => {
this.current.secureJsonData = this.current.secureJsonData || {};
this.current.secureJsonData.password = event.currentTarget.value;
};
} }
...@@ -17,15 +17,15 @@ ...@@ -17,15 +17,15 @@
<span class="gf-form-label width-7">User</span> <span class="gf-form-label width-7">User</span>
<input type="text" class="gf-form-input" ng-model='ctrl.current.user' placeholder="user"></input> <input type="text" class="gf-form-input" ng-model='ctrl.current.user' placeholder="user"></input>
</div> </div>
<div class="gf-form max-width-15" ng-if="!ctrl.current.secureJsonFields.password"> <div class="gf-form">
<span class="gf-form-label width-7">Password</span> <secret-form-field
<input type="password" class="gf-form-input" ng-model='ctrl.current.secureJsonData.password' placeholder="password"></input> isConfigured="ctrl.current.secureJsonFields.password"
</div> value="ctrl.current.secureJsonData.password"
<div class="gf-form max-width-19" ng-if="ctrl.current.secureJsonFields.password"> on-reset="ctrl.onPasswordReset"
<span class="gf-form-label width-7">Password</span> on-change="ctrl.onPasswordChange"
<input type="text" class="gf-form-input" disabled="disabled" value="configured"> inputWidth="9"
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.password = false">reset</a> />
</div> </div>
</div> </div>
<div class="gf-form"> <div class="gf-form">
......
import _ from 'lodash'; import _ from 'lodash';
import { SyntheticEvent } from 'react';
export class PostgresConfigCtrl { export class PostgresConfigCtrl {
static templateUrl = 'partials/config.html'; static templateUrl = 'partials/config.html';
...@@ -52,6 +53,18 @@ export class PostgresConfigCtrl { ...@@ -52,6 +53,18 @@ export class PostgresConfigCtrl {
this.showTimescaleDBHelp = !this.showTimescaleDBHelp; this.showTimescaleDBHelp = !this.showTimescaleDBHelp;
} }
onPasswordReset = (event: SyntheticEvent<HTMLInputElement>) => {
event.preventDefault();
this.current.secureJsonFields.password = false;
this.current.secureJsonData = this.current.secureJsonData || {};
this.current.secureJsonData.password = '';
};
onPasswordChange = (event: SyntheticEvent<HTMLInputElement>) => {
this.current.secureJsonData = this.current.secureJsonData || {};
this.current.secureJsonData.password = event.currentTarget.value;
};
// the value portion is derived from postgres server_version_num/100 // the value portion is derived from postgres server_version_num/100
postgresVersions = [ postgresVersions = [
{ name: '9.3', value: 903 }, { name: '9.3', value: 903 },
......
...@@ -17,16 +17,17 @@ ...@@ -17,16 +17,17 @@
<span class="gf-form-label width-7">User</span> <span class="gf-form-label width-7">User</span>
<input type="text" class="gf-form-input" ng-model='ctrl.current.user' placeholder="user"></input> <input type="text" class="gf-form-input" ng-model='ctrl.current.user' placeholder="user"></input>
</div> </div>
<div class="gf-form max-width-15" ng-if="!ctrl.current.secureJsonFields.password"> <div class="gf-form">
<span class="gf-form-label width-7">Password</span> <secret-form-field
<input type="password" class="gf-form-input" ng-model='ctrl.current.secureJsonData.password' placeholder="password"></input> isConfigured="ctrl.current.secureJsonFields.password"
</div> value="ctrl.current.secureJsonData.password"
<div class="gf-form max-width-19" ng-if="ctrl.current.secureJsonFields.password"> on-reset="ctrl.onPasswordReset"
<span class="gf-form-label width-7">Password</span> on-change="ctrl.onPasswordChange"
<input type="text" class="gf-form-input" disabled="disabled" value="configured"> inputWidth="9"
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.password = false">reset</a> />
</div> </div>
</div> </div>
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-7">SSL Mode</label> <label class="gf-form-label width-7">SSL Mode</label>
<div class="gf-form-select-wrapper max-width-15 gf-form-select-wrapper--has-help-icon"> <div class="gf-form-select-wrapper max-width-15 gf-form-select-wrapper--has-help-icon">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment