ソースを参照

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

Secret input field component
Torkel Ödegaard 6 年 前
コミット
0091b86e9c

+ 16 - 3
packages/grafana-ui/src/components/FormField/FormField.test.tsx

@@ -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();
+  });
 });

+ 8 - 3
packages/grafana-ui/src/components/FormField/FormField.tsx

@@ -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 };

+ 19 - 1
packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap

@@ -1,6 +1,24 @@
 // 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"
 >

+ 38 - 0
packages/grafana-ui/src/components/SecretFormFied/SecretFormField.story.tsx

@@ -0,0 +1,38 @@
+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>
+  );
+});

+ 71 - 0
packages/grafana-ui/src/components/SecretFormFied/SecretFormField.tsx

@@ -0,0 +1,71 @@
+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';

+ 1 - 0
packages/grafana-ui/src/components/index.ts

@@ -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';

+ 1 - 1
packages/grafana-ui/src/utils/storybook/UseState.tsx

@@ -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 }> {

+ 8 - 1
public/app/core/angular_wrappers.ts

@@ -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 }],
+  ]);
 }

+ 15 - 6
public/app/core/services/ng_react.ts

@@ -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.

+ 14 - 0
public/app/plugins/datasource/mssql/config_ctrl.ts

@@ -1,3 +1,5 @@
+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;
+  };
 }

+ 9 - 9
public/app/plugins/datasource/mssql/partials/config.html

@@ -17,15 +17,15 @@
 			<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>
+    <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">

+ 13 - 0
public/app/plugins/datasource/postgres/config_ctrl.ts

@@ -1,4 +1,5 @@
 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 },

+ 9 - 8
public/app/plugins/datasource/postgres/partials/config.html

@@ -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">