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';
import { shallow } from 'enzyme';
import { FormField, Props } from './FormField';
const setup = (propOverrides?: object) => {
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
label: 'Test',
labelWidth: 11,
......@@ -15,10 +15,23 @@ const setup = (propOverrides?: object) => {
return shallow(<FormField {...props} />);
};
describe('Render', () => {
it('should render component', () => {
describe('FormField', () => {
it('should render component with default inputEl', () => {
const wrapper = setup();
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> {
label: string;
labelWidth?: number;
inputWidth?: number;
inputEl?: React.ReactNode;
}
const defaultProps = {
......@@ -12,14 +13,18 @@ const defaultProps = {
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 (
<div className="form-field">
<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>
);
};
FormField.displayName = 'FormField';
FormField.defaultProps = defaultProps;
export { FormField };
// 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
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';
// Forms
export { FormLabel } from './FormLabel/FormLabel';
export { FormField } from './FormField/FormField';
export { SecretFormField } from './SecretFormFied/SecretFormField';
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
......
......@@ -2,7 +2,7 @@ import React from 'react';
interface StateHolderProps<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 }> {
......
......@@ -9,7 +9,7 @@ import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import { MetricSelect } from './components/Select/MetricSelect';
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';
export function registerAngularDirectives() {
......@@ -59,4 +59,11 @@ export function registerAngularDirectives() {
['datasource', { 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 @@
// - reactComponent (generic directive for delegating off to React Components)
// - reactDirective (factory for creating specific directives that correspond to reactComponent directives)
import { kebabCase } from 'lodash';
import React from 'react';
import ReactDOM from 'react-dom';
import angular from 'angular';
......@@ -155,11 +156,17 @@ function getPropExpression(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) {
const index = Object.keys(attrs).filter(attr => {
return attr.toLowerCase() === propName.toLowerCase();
})[0];
/**
* Finds the normalized attribute knowing that React props accept any type of capitalization and it also handles
* kabab case attributes which can be used in case the attribute would also be a standard html attribute and would be
* evaluated by the browser as such.
* @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];
}
......@@ -274,7 +281,9 @@ const reactDirective = $injector => {
// watch each property name and trigger an update whenever something changes,
// to update scope.props with new values
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.
......
import { SyntheticEvent } from 'react';
export class MssqlConfigCtrl {
static templateUrl = 'partials/config.html';
......@@ -7,4 +9,16 @@ export class MssqlConfigCtrl {
constructor($scope) {
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,14 +17,14 @@
<span class="gf-form-label width-7">User</span>
<input type="text" class="gf-form-input" ng-model='ctrl.current.user' placeholder="user"></input>
</div>
<div class="gf-form max-width-15" ng-if="!ctrl.current.secureJsonFields.password">
<span class="gf-form-label width-7">Password</span>
<input type="password" class="gf-form-input" ng-model='ctrl.current.secureJsonData.password' placeholder="password"></input>
</div>
<div class="gf-form max-width-19" ng-if="ctrl.current.secureJsonFields.password">
<span class="gf-form-label width-7">Password</span>
<input type="text" class="gf-form-input" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.password = false">reset</a>
<div class="gf-form">
<secret-form-field
isConfigured="ctrl.current.secureJsonFields.password"
value="ctrl.current.secureJsonData.password"
on-reset="ctrl.onPasswordReset"
on-change="ctrl.onPasswordChange"
inputWidth="9"
/>
</div>
</div>
......
import _ from 'lodash';
import { SyntheticEvent } from 'react';
export class PostgresConfigCtrl {
static templateUrl = 'partials/config.html';
......@@ -52,6 +53,18 @@ export class PostgresConfigCtrl {
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
postgresVersions = [
{ name: '9.3', value: 903 },
......
......@@ -17,16 +17,17 @@
<span class="gf-form-label width-7">User</span>
<input type="text" class="gf-form-input" ng-model='ctrl.current.user' placeholder="user"></input>
</div>
<div class="gf-form max-width-15" ng-if="!ctrl.current.secureJsonFields.password">
<span class="gf-form-label width-7">Password</span>
<input type="password" class="gf-form-input" ng-model='ctrl.current.secureJsonData.password' placeholder="password"></input>
</div>
<div class="gf-form max-width-19" ng-if="ctrl.current.secureJsonFields.password">
<span class="gf-form-label width-7">Password</span>
<input type="text" class="gf-form-input" disabled="disabled" value="configured">
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.password = false">reset</a>
<div class="gf-form">
<secret-form-field
isConfigured="ctrl.current.secureJsonFields.password"
value="ctrl.current.secureJsonData.password"
on-reset="ctrl.onPasswordReset"
on-change="ctrl.onPasswordChange"
inputWidth="9"
/>
</div>
</div>
<div class="gf-form">
<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">
......
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