Browse Source

ref(ui) Update old forms to use new context (#26108)

Update the old form library to not use legacy context anymore.
The default value of the new context is odd shaped to maintain backwards
compatibility for 'detached' inputs. We have a few forms that use old
form input/field components without a wrapping FormContext. This
requires the default context value to be undefined so that inputs behave
as uncontrolled inputs.
Mark Story 3 years ago
parent
commit
b2db79f381

+ 47 - 54
static/app/components/forms/form.tsx

@@ -1,8 +1,8 @@
 import * as React from 'react';
 import styled from '@emotion/styled';
 import isEqual from 'lodash/isEqual';
-import PropTypes from 'prop-types';
 
+import FormContext, {FormContextData} from 'app/components/forms/formContext';
 import FormState from 'app/components/forms/state';
 import {t} from 'app/locale';
 
@@ -35,22 +35,13 @@ type FormClassState = {
   state: FormState;
 };
 
-export type Context = {
-  form: {
-    errors: object;
-    data: object;
-    onFieldChange: (name: string, value: string | number) => void;
-  };
-};
+// Re-export for compatibility alias.
+export type Context = FormContextData;
 
 class Form<
   Props extends FormProps = FormProps,
   State extends FormClassState = FormClassState
 > extends React.Component<Props, State> {
-  static childContextTypes = {
-    form: PropTypes.object.isRequired,
-  };
-
   static defaultProps = {
     cancelLabel: t('Cancel'),
     submitLabel: t('Save Changes'),
@@ -75,7 +66,7 @@ class Form<
     } as State;
   }
 
-  getChildContext() {
+  getContext() {
     const {data, errors} = this.state;
     return {
       form: {
@@ -138,50 +129,52 @@ class Form<
     const nonFieldErrors = this.state.errors && this.state.errors.non_field_errors;
 
     return (
-      <StyledForm onSubmit={this.onSubmit} className={this.props.className}>
-        {isError && !hideErrors && (
-          <div className="alert alert-error alert-block">
-            {nonFieldErrors ? (
-              <div>
-                <p>
-                  {t(
-                    'Unable to save your changes. Please correct the following errors try again.'
-                  )}
-                </p>
-                <ul>
-                  {nonFieldErrors.map((e, i) => (
-                    <li key={i}>{e}</li>
-                  ))}
-                </ul>
-              </div>
-            ) : (
-              errorMessage
-            )}
-          </div>
-        )}
-        {this.props.children}
-        <div className={this.props.footerClass} style={{marginTop: 25}}>
-          <button
-            className="btn btn-primary"
-            disabled={isSaving || this.props.submitDisabled || !hasChanges}
-            type="submit"
-          >
-            {this.props.submitLabel}
-          </button>
-          {this.props.onCancel && (
+      <FormContext.Provider value={this.getContext()}>
+        <StyledForm onSubmit={this.onSubmit} className={this.props.className}>
+          {isError && !hideErrors && (
+            <div className="alert alert-error alert-block">
+              {nonFieldErrors ? (
+                <div>
+                  <p>
+                    {t(
+                      'Unable to save your changes. Please correct the following errors try again.'
+                    )}
+                  </p>
+                  <ul>
+                    {nonFieldErrors.map((e, i) => (
+                      <li key={i}>{e}</li>
+                    ))}
+                  </ul>
+                </div>
+              ) : (
+                errorMessage
+              )}
+            </div>
+          )}
+          {this.props.children}
+          <div className={this.props.footerClass} style={{marginTop: 25}}>
             <button
-              type="button"
-              className="btn btn-default"
-              disabled={isSaving}
-              onClick={this.props.onCancel}
-              style={{marginLeft: 5}}
+              className="btn btn-primary"
+              disabled={isSaving || this.props.submitDisabled || !hasChanges}
+              type="submit"
             >
-              {this.props.cancelLabel}
+              {this.props.submitLabel}
             </button>
-          )}
-          {this.props.extraButton}
-        </div>
-      </StyledForm>
+            {this.props.onCancel && (
+              <button
+                type="button"
+                className="btn btn-default"
+                disabled={isSaving}
+                onClick={this.props.onCancel}
+                style={{marginLeft: 5}}
+              >
+                {this.props.cancelLabel}
+              </button>
+            )}
+            {this.props.extraButton}
+          </div>
+        </StyledForm>
+      </FormContext.Provider>
     );
   }
 }

+ 24 - 0
static/app/components/forms/formContext.tsx

@@ -0,0 +1,24 @@
+import {createContext} from 'react';
+
+/**
+ * Context type used on 'classic' or 'old' forms.
+ *
+ * This is a very different type than what is used on the 'settings'
+ * forms which have MobX under the hood.
+ */
+export type FormContextData = {
+  form?: {
+    errors: object;
+    data: object;
+    onFieldChange: (name: string, value: string | number) => void;
+  };
+};
+
+/**
+ * Default to undefined to preserve backwards compatibility.
+ * The FormField component uses a truthy test to see if it is connected
+ * to context or if the control is 'uncontrolled'.
+ */
+const FormContext = createContext<FormContextData>({});
+
+export default FormContext;

+ 6 - 9
static/app/components/forms/formField.tsx

@@ -1,9 +1,8 @@
 import * as React from 'react';
 import styled from '@emotion/styled';
 import classNames from 'classnames';
-import PropTypes from 'prop-types';
 
-import {Context} from 'app/components/forms/form';
+import FormContext, {FormContextData} from 'app/components/forms/formContext';
 import QuestionTooltip from 'app/components/questionTooltip';
 import {Meta} from 'app/types';
 import {defined} from 'app/utils';
@@ -39,10 +38,6 @@ export default class FormField<
   Props extends FormFieldProps = FormFieldProps,
   State extends FormFieldState = FormFieldState
 > extends React.PureComponent<Props, State> {
-  static contextTypes = {
-    form: PropTypes.object,
-  };
-
   static defaultProps: DefaultProps = {
     hideErrorMessage: false,
     disabled: false,
@@ -59,7 +54,7 @@ export default class FormField<
 
   componentDidMount() {}
 
-  UNSAFE_componentWillReceiveProps(nextProps: Props, nextContext: Context) {
+  UNSAFE_componentWillReceiveProps(nextProps: Props, nextContext: FormContextData) {
     const newError = this.getError(nextProps, nextContext);
     if (newError !== this.state.error) {
       this.setState({error: newError});
@@ -74,7 +69,9 @@ export default class FormField<
 
   componentWillUnmount() {}
 
-  getValue(props: Props, context: Context) {
+  static contextType = FormContext;
+
+  getValue(props: Props, context: FormContextData) {
     const form = (context || this.context || {}).form;
     props = props || this.props;
     if (defined(props.value)) {
@@ -86,7 +83,7 @@ export default class FormField<
     return defined(props.defaultValue) ? props.defaultValue : '';
   }
 
-  getError(props: Props, context: Context) {
+  getError(props: Props, context: FormContextData) {
     const form = (context || this.context || {}).form;
     props = props || this.props;
     if (defined(props.error)) {

+ 6 - 11
tests/js/spec/components/forms/booleanField.spec.jsx

@@ -1,6 +1,6 @@
 import {mountWithTheme} from 'sentry-test/enzyme';
 
-import {BooleanField} from 'app/components/forms';
+import {BooleanField, Form} from 'app/components/forms';
 
 describe('BooleanField', function () {
   describe('render()', function () {
@@ -10,16 +10,11 @@ describe('BooleanField', function () {
     });
 
     it('renders with form context', function () {
-      const wrapper = mountWithTheme(<BooleanField name="fieldName" />, {
-        context: {
-          form: {
-            data: {
-              fieldName: true,
-            },
-            errors: {},
-          },
-        },
-      });
+      const wrapper = mountWithTheme(
+        <Form initialData={{fieldName: true}}>
+          <BooleanField name="fieldName" />
+        </Form>
+      );
       expect(wrapper).toSnapshot();
     });
   });

+ 6 - 11
tests/js/spec/components/forms/emailField.spec.jsx

@@ -1,6 +1,6 @@
 import {mountWithTheme} from 'sentry-test/enzyme';
 
-import {EmailField} from 'app/components/forms';
+import {EmailField, Form} from 'app/components/forms';
 
 describe('EmailField', function () {
   describe('render()', function () {
@@ -17,16 +17,11 @@ describe('EmailField', function () {
     });
 
     it('renders with form context', function () {
-      const wrapper = mountWithTheme(<EmailField name="fieldName" />, {
-        context: {
-          form: {
-            data: {
-              fieldName: 'foo@example.com',
-            },
-            errors: {},
-          },
-        },
-      });
+      const wrapper = mountWithTheme(
+        <Form initialData={{fieldName: 'foo@example.com'}}>
+          <EmailField name="fieldName" />
+        </Form>
+      );
       expect(wrapper).toSnapshot();
     });
   });

+ 10 - 17
tests/js/spec/components/forms/multiSelectField.spec.jsx

@@ -1,6 +1,7 @@
 import {mountWithTheme} from 'sentry-test/enzyme';
 
 import {MultiSelectField} from 'app/components/forms';
+import Form from 'app/components/forms/form';
 
 describe('MultiSelectField', function () {
   describe('render()', function () {
@@ -33,23 +34,15 @@ describe('MultiSelectField', function () {
 
     it('renders with form context', function () {
       const wrapper = mountWithTheme(
-        <MultiSelectField
-          options={[
-            {label: 'a', value: 'a'},
-            {label: 'b', value: 'b'},
-          ]}
-          name="fieldName"
-        />,
-        {
-          context: {
-            form: {
-              data: {
-                fieldName: ['a', 'b'],
-              },
-              errors: {},
-            },
-          },
-        }
+        <Form initialData={{fieldName: ['a', 'b']}}>
+          <MultiSelectField
+            options={[
+              {label: 'a', value: 'a'},
+              {label: 'b', value: 'b'},
+            ]}
+            name="fieldName"
+          />
+        </Form>
       );
 
       expect(wrapper.find('StyledSelectControl').prop('value')).toEqual(['a', 'b']);

+ 5 - 10
tests/js/spec/components/forms/numberField.spec.jsx

@@ -21,16 +21,11 @@ describe('NumberField', function () {
     });
 
     it('renders with form context', function () {
-      const wrapper = mountWithTheme(<NumberField name="fieldName" />, {
-        context: {
-          form: {
-            data: {
-              fieldName: 5,
-            },
-            errors: {},
-          },
-        },
-      });
+      const wrapper = mountWithTheme(
+        <Form initialData={{fieldName: 5}}>
+          <NumberField name="fieldName" />
+        </Form>
+      );
       expect(wrapper).toSnapshot();
     });
 

+ 6 - 11
tests/js/spec/components/forms/passwordField.spec.jsx

@@ -1,6 +1,6 @@
 import {mountWithTheme} from 'sentry-test/enzyme';
 
-import {PasswordField} from 'app/components/forms';
+import {Form, PasswordField} from 'app/components/forms';
 
 describe('PasswordField', function () {
   describe('render()', function () {
@@ -15,16 +15,11 @@ describe('PasswordField', function () {
     });
 
     it('renders with form context', function () {
-      const wrapper = mountWithTheme(<PasswordField name="fieldName" />, {
-        context: {
-          form: {
-            data: {
-              fieldName: 'foobar',
-            },
-            errors: {},
-          },
-        },
-      });
+      const wrapper = mountWithTheme(
+        <Form initialData={{fieldName: 'foobar'}}>
+          <PasswordField name="fieldName" />
+        </Form>
+      );
       expect(wrapper).toSnapshot();
     });
   });

+ 4 - 12
tests/js/spec/components/forms/radioBooleanField.spec.jsx

@@ -1,6 +1,6 @@
 import {mountWithTheme} from 'sentry-test/enzyme';
 
-import {RadioBooleanField} from 'app/components/forms';
+import {Form, RadioBooleanField} from 'app/components/forms';
 import NewRadioBooleanField from 'app/views/settings/components/forms/radioBooleanField';
 
 describe('RadioBooleanField', function () {
@@ -14,17 +14,9 @@ describe('RadioBooleanField', function () {
 
     it('renders with form context', function () {
       const wrapper = mountWithTheme(
-        <RadioBooleanField name="fieldName" yesLabel="Yes" noLabel="No" />,
-        {
-          context: {
-            form: {
-              data: {
-                fieldName: true,
-              },
-              errors: {},
-            },
-          },
-        }
+        <Form initialData={{fieldName: true}}>
+          <RadioBooleanField name="fieldName" yesLabel="Yes" noLabel="No" />
+        </Form>
       );
       expect(wrapper).toSnapshot();
     });

+ 6 - 11
tests/js/spec/components/forms/textField.spec.jsx

@@ -1,6 +1,6 @@
 import {mountWithTheme} from 'sentry-test/enzyme';
 
-import {TextField} from 'app/components/forms';
+import {Form, TextField} from 'app/components/forms';
 
 describe('TextField', function () {
   describe('render()', function () {
@@ -10,16 +10,11 @@ describe('TextField', function () {
     });
 
     it('renders with form context', function () {
-      const wrapper = mountWithTheme(<TextField name="fieldName" />, {
-        context: {
-          form: {
-            data: {
-              fieldName: 'fieldValue',
-            },
-            errors: {},
-          },
-        },
-      });
+      const wrapper = mountWithTheme(
+        <Form initialData={{fieldName: 'fieldValue'}}>
+          <TextField name="fieldName" />
+        </Form>
+      );
       expect(wrapper).toSnapshot();
     });
   });