Repository: vazco/uniforms (ts)

Issue:
Enable use of 'map' type from JSONSchema
Loving this library...saving so much time.
Quick question/ask
Getting the error-
```
Invariant Violation: Unsupported field type: map
```
Im using a 'map' in my models on a language field that allows data to be stored like -
``` 
"summary" : {
        "language" : {
            "en" : "Hello World"
        }
    },
```

With a schema that looks like this-
```{
  "title": "epochItem",
  "type": "object",
  "properties":
    "name": {
      "type": "string",
      "description": "Human Readable"
    },
    "summary": {
      "title": "summary",
      "type": "object",
      "properties": {
        "name": {
          "type": "string"
        },
        "language": {
          "type": "map",
          "properties": {
            "$*": {
              "type": "string"
            }
          }
        }
      },
      "description": "Complex Description of Summary"
    }
...
(More fields)
``` 


Any chance this is on the roadmap to be supported?


Pull request description:
Added support for objects without properties (closes #934).
This PR adds support for `object` fields without `properties` in `JSONSchemaBridge`, closing #934.

Patch:
diff --git a/packages/uniforms-bootstrap3/src/DateField.tsx b/packages/uniforms-bootstrap3/src/DateField.tsx
index 5fc3f9c0..0d378d9c 100644
--- a/packages/uniforms-bootstrap3/src/DateField.tsx
+++ b/packages/uniforms-bootstrap3/src/DateField.tsx
@@ -4,6 +4,7 @@ import { connectField, HTMLFieldProps } from 'uniforms';
 
 import wrapField from './wrapField';
 
+/* istanbul ignore next */
 const DateConstructor = (typeof global === 'object' ? global : window).Date;
 const dateFormat = (value?: Date) => value?.toISOString().slice(0, -8);
 
diff --git a/packages/uniforms-bootstrap3/src/NestField.tsx b/packages/uniforms-bootstrap3/src/NestField.tsx
index fd299826..cad80a4c 100644
--- a/packages/uniforms-bootstrap3/src/NestField.tsx
+++ b/packages/uniforms-bootstrap3/src/NestField.tsx
@@ -32,7 +32,7 @@ const Nest = ({
     )}
 
     {children ||
-      fields?.map(field => (
+      fields.map(field => (
         <AutoField key={field} name={field} {...itemProps} />
       ))}
   </div>
diff --git a/packages/uniforms-bootstrap3/src/RadioField.tsx b/packages/uniforms-bootstrap3/src/RadioField.tsx
index 88313ce9..d7299c99 100644
--- a/packages/uniforms-bootstrap3/src/RadioField.tsx
+++ b/packages/uniforms-bootstrap3/src/RadioField.tsx
@@ -4,10 +4,10 @@ import { connectField, HTMLFieldProps } from 'uniforms';
 
 import wrapField from './wrapField';
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 export type RadioFieldProps = HTMLFieldProps<
diff --git a/packages/uniforms-bootstrap3/src/SelectField.tsx b/packages/uniforms-bootstrap3/src/SelectField.tsx
index 36852d7a..7d70bd99 100644
--- a/packages/uniforms-bootstrap3/src/SelectField.tsx
+++ b/packages/uniforms-bootstrap3/src/SelectField.tsx
@@ -5,10 +5,10 @@ import { connectField, HTMLFieldProps } from 'uniforms';
 
 import wrapField from './wrapField';
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 export type SelectFieldProps = HTMLFieldProps<
diff --git a/packages/uniforms-bootstrap4/src/DateField.tsx b/packages/uniforms-bootstrap4/src/DateField.tsx
index 27003567..43e875b7 100644
--- a/packages/uniforms-bootstrap4/src/DateField.tsx
+++ b/packages/uniforms-bootstrap4/src/DateField.tsx
@@ -4,6 +4,7 @@ import { connectField, HTMLFieldProps } from 'uniforms';
 
 import wrapField from './wrapField';
 
+/* istanbul ignore next */
 const DateConstructor = (typeof global === 'object' ? global : window).Date;
 const dateFormat = (value?: Date) => value?.toISOString().slice(0, -8);
 
diff --git a/packages/uniforms-bootstrap4/src/NestField.tsx b/packages/uniforms-bootstrap4/src/NestField.tsx
index dfde5bc1..dd36313b 100644
--- a/packages/uniforms-bootstrap4/src/NestField.tsx
+++ b/packages/uniforms-bootstrap4/src/NestField.tsx
@@ -33,7 +33,7 @@ function Nest({
       )}
 
       {children ||
-        fields?.map(field => (
+        fields.map(field => (
           <AutoField key={field} name={field} {...itemProps} />
         ))}
     </div>
diff --git a/packages/uniforms-bootstrap4/src/RadioField.tsx b/packages/uniforms-bootstrap4/src/RadioField.tsx
index 8226894a..41e2401b 100644
--- a/packages/uniforms-bootstrap4/src/RadioField.tsx
+++ b/packages/uniforms-bootstrap4/src/RadioField.tsx
@@ -4,10 +4,10 @@ import { connectField, HTMLFieldProps } from 'uniforms';
 
 import wrapField from './wrapField';
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 export type RadioFieldProps = HTMLFieldProps<
diff --git a/packages/uniforms-bootstrap4/src/SelectField.tsx b/packages/uniforms-bootstrap4/src/SelectField.tsx
index aac04433..dded1178 100644
--- a/packages/uniforms-bootstrap4/src/SelectField.tsx
+++ b/packages/uniforms-bootstrap4/src/SelectField.tsx
@@ -5,10 +5,10 @@ import { connectField, HTMLFieldProps } from 'uniforms';
 
 import wrapField from './wrapField';
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 export type SelectFieldProps = HTMLFieldProps<
diff --git a/packages/uniforms-bootstrap5/src/DateField.tsx b/packages/uniforms-bootstrap5/src/DateField.tsx
index 27003567..43e875b7 100644
--- a/packages/uniforms-bootstrap5/src/DateField.tsx
+++ b/packages/uniforms-bootstrap5/src/DateField.tsx
@@ -4,6 +4,7 @@ import { connectField, HTMLFieldProps } from 'uniforms';
 
 import wrapField from './wrapField';
 
+/* istanbul ignore next */
 const DateConstructor = (typeof global === 'object' ? global : window).Date;
 const dateFormat = (value?: Date) => value?.toISOString().slice(0, -8);
 
diff --git a/packages/uniforms-bootstrap5/src/NestField.tsx b/packages/uniforms-bootstrap5/src/NestField.tsx
index dfde5bc1..dd36313b 100644
--- a/packages/uniforms-bootstrap5/src/NestField.tsx
+++ b/packages/uniforms-bootstrap5/src/NestField.tsx
@@ -33,7 +33,7 @@ function Nest({
       )}
 
       {children ||
-        fields?.map(field => (
+        fields.map(field => (
           <AutoField key={field} name={field} {...itemProps} />
         ))}
     </div>
diff --git a/packages/uniforms-bootstrap5/src/RadioField.tsx b/packages/uniforms-bootstrap5/src/RadioField.tsx
index dff61e48..c80fed06 100644
--- a/packages/uniforms-bootstrap5/src/RadioField.tsx
+++ b/packages/uniforms-bootstrap5/src/RadioField.tsx
@@ -4,10 +4,10 @@ import { connectField, HTMLFieldProps } from 'uniforms';
 
 import wrapField from './wrapField';
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 export type RadioFieldProps = HTMLFieldProps<
diff --git a/packages/uniforms-bootstrap5/src/SelectField.tsx b/packages/uniforms-bootstrap5/src/SelectField.tsx
index 90895fdd..6a308fac 100644
--- a/packages/uniforms-bootstrap5/src/SelectField.tsx
+++ b/packages/uniforms-bootstrap5/src/SelectField.tsx
@@ -5,10 +5,10 @@ import { connectField, HTMLFieldProps } from 'uniforms';
 
 import wrapField from './wrapField';
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 export type SelectFieldProps = HTMLFieldProps<
diff --git a/packages/uniforms-bridge-json-schema/src/JSONSchemaBridge.ts b/packages/uniforms-bridge-json-schema/src/JSONSchemaBridge.ts
index 80eef19d..22b2a93c 100644
--- a/packages/uniforms-bridge-json-schema/src/JSONSchemaBridge.ts
+++ b/packages/uniforms-bridge-json-schema/src/JSONSchemaBridge.ts
@@ -79,6 +79,7 @@ export default class JSONSchemaBridge extends Bridge {
     super();
 
     this.schema = distinctSchema(schema);
+    this._compiledSchema[''] = this.schema;
 
     // Memoize for performance and referential equality.
     this.getField = memoize(this.getField.bind(this));
@@ -301,23 +302,15 @@ export default class JSONSchemaBridge extends Bridge {
     return ready;
   }
 
-  getSubfields(name?: string) {
-    if (!name) {
-      if (this.schema.properties) {
-        return Object.keys(this.schema.properties);
-      }
-
-      return [];
-    }
-
-    const { type: _type, properties: _properties } = this.getField(name);
+  getSubfields(name = '') {
+    const field = this.getField(name);
     const {
-      type: fieldType = _type,
-      properties: fieldProperties = _properties,
+      properties = field.properties,
+      type = field.type,
     } = this._compiledSchema[name];
 
-    if (fieldType === 'object') {
-      return Object.keys(fieldProperties);
+    if (type === 'object' && properties) {
+      return Object.keys(properties);
     }
 
     return [];
diff --git a/packages/uniforms-material/src/DateField.tsx b/packages/uniforms-material/src/DateField.tsx
index 76281bba..452c5bf1 100644
--- a/packages/uniforms-material/src/DateField.tsx
+++ b/packages/uniforms-material/src/DateField.tsx
@@ -2,6 +2,7 @@ import TextField, { StandardTextFieldProps } from '@material-ui/core/TextField';
 import React from 'react';
 import { FieldProps, connectField, filterDOMProps } from 'uniforms';
 
+/* istanbul ignore next */
 const DateConstructor = (typeof global === 'object' ? global : window).Date;
 const dateFormat = (value?: Date) => value && value.toISOString().slice(0, -8);
 const dateParse = (timestamp: number, onChange: DateFieldProps['onChange']) => {
diff --git a/packages/uniforms-material/src/NestField.tsx b/packages/uniforms-material/src/NestField.tsx
index c985bb95..cd27f772 100644
--- a/packages/uniforms-material/src/NestField.tsx
+++ b/packages/uniforms-material/src/NestField.tsx
@@ -25,7 +25,7 @@ function Nest({
     { ...props, component: undefined, fullWidth, margin },
     label && <FormLabel component="legend">{label}</FormLabel>,
     children ||
-      fields?.map(field => (
+      fields.map(field => (
         <AutoField key={field} name={field} {...itemProps} />
       )),
   );
diff --git a/packages/uniforms-material/src/SelectField.tsx b/packages/uniforms-material/src/SelectField.tsx
index 3df5f4e1..ca4d1a96 100644
--- a/packages/uniforms-material/src/SelectField.tsx
+++ b/packages/uniforms-material/src/SelectField.tsx
@@ -49,10 +49,10 @@ type SelectProps = FieldProps<
 export type SelectFieldProps = (CheckboxesProps | SelectProps) &
   SelectFieldCommonProps;
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 // eslint-disable-next-line complexity
diff --git a/packages/uniforms-semantic/src/DateField.tsx b/packages/uniforms-semantic/src/DateField.tsx
index 0e35da88..b1932c74 100644
--- a/packages/uniforms-semantic/src/DateField.tsx
+++ b/packages/uniforms-semantic/src/DateField.tsx
@@ -2,6 +2,7 @@ import classnames from 'classnames';
 import React, { Ref } from 'react';
 import { HTMLFieldProps, connectField, filterDOMProps } from 'uniforms';
 
+/* istanbul ignore next */
 const DateConstructor = (typeof global === 'object' ? global : window).Date;
 const dateFormat = (value?: Date) => value?.toISOString().slice(0, -8);
 
diff --git a/packages/uniforms-semantic/src/NestField.tsx b/packages/uniforms-semantic/src/NestField.tsx
index 4d80ac21..1eac1480 100644
--- a/packages/uniforms-semantic/src/NestField.tsx
+++ b/packages/uniforms-semantic/src/NestField.tsx
@@ -39,7 +39,7 @@ function Nest({
       )}
 
       {children ||
-        fields?.map(field => (
+        fields.map(field => (
           <AutoField key={field} name={field} {...itemProps} />
         ))}
     </div>
diff --git a/packages/uniforms-semantic/src/RadioField.tsx b/packages/uniforms-semantic/src/RadioField.tsx
index 12375b0b..76aa9910 100644
--- a/packages/uniforms-semantic/src/RadioField.tsx
+++ b/packages/uniforms-semantic/src/RadioField.tsx
@@ -3,10 +3,10 @@ import omit from 'lodash/omit';
 import React from 'react';
 import { connectField, filterDOMProps, HTMLFieldProps } from 'uniforms';
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 export type RadioFieldProps = HTMLFieldProps<
diff --git a/packages/uniforms-semantic/src/SelectField.tsx b/packages/uniforms-semantic/src/SelectField.tsx
index ca4392ab..e6d3ce9f 100644
--- a/packages/uniforms-semantic/src/SelectField.tsx
+++ b/packages/uniforms-semantic/src/SelectField.tsx
@@ -3,10 +3,10 @@ import xor from 'lodash/xor';
 import React, { Ref } from 'react';
 import { connectField, filterDOMProps, HTMLFieldProps } from 'uniforms';
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 const selectStyle = { paddingBottom: 0, paddingTop: 0 };
diff --git a/packages/uniforms-unstyled/src/DateField.tsx b/packages/uniforms-unstyled/src/DateField.tsx
index 9c3cd01b..70c5b1d9 100644
--- a/packages/uniforms-unstyled/src/DateField.tsx
+++ b/packages/uniforms-unstyled/src/DateField.tsx
@@ -1,6 +1,7 @@
 import React, { Ref } from 'react';
 import { HTMLFieldProps, connectField, filterDOMProps } from 'uniforms';
 
+/* istanbul ignore next */
 const DateConstructor = (typeof global === 'object' ? global : window).Date;
 const dateFormat = (value?: Date) => value?.toISOString().slice(0, -8);
 
diff --git a/packages/uniforms-unstyled/src/RadioField.tsx b/packages/uniforms-unstyled/src/RadioField.tsx
index 9c1fd85f..d9f93717 100644
--- a/packages/uniforms-unstyled/src/RadioField.tsx
+++ b/packages/uniforms-unstyled/src/RadioField.tsx
@@ -2,10 +2,10 @@ import omit from 'lodash/omit';
 import React from 'react';
 import { HTMLFieldProps, connectField, filterDOMProps } from 'uniforms';
 
-const base64 =
-  typeof btoa !== 'undefined'
-    ? btoa
-    : (x: string) => Buffer.from(x).toString('base64');
+const base64: typeof btoa =
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 export type RadioFieldProps = HTMLFieldProps<
diff --git a/packages/uniforms-unstyled/src/SelectField.tsx b/packages/uniforms-unstyled/src/SelectField.tsx
index e0a43556..6023e774 100644
--- a/packages/uniforms-unstyled/src/SelectField.tsx
+++ b/packages/uniforms-unstyled/src/SelectField.tsx
@@ -3,7 +3,9 @@ import React, { Ref } from 'react';
 import { HTMLFieldProps, connectField, filterDOMProps } from 'uniforms';
 
 const base64: typeof btoa =
-  typeof btoa !== 'undefined' ? btoa : x => Buffer.from(x).toString('base64');
+  typeof btoa === 'undefined'
+    ? /* istanbul ignore next */ x => Buffer.from(x).toString('base64')
+    : btoa;
 const escape = (x: string) => base64(encodeURIComponent(x)).replace(/=+$/, '');
 
 export type SelectFieldProps = HTMLFieldProps<
diff --git a/packages/uniforms/src/useField.tsx b/packages/uniforms/src/useField.tsx
index 96d5710f..4f3affd7 100644
--- a/packages/uniforms/src/useField.tsx
+++ b/packages/uniforms/src/useField.tsx
@@ -19,6 +19,7 @@ function propagate(
   const value =
     prop === '' ||
     prop === false ||
+    prop === null ||
     (forcedFallbackInProp && (forcedFallbackInSchema || !state))
       ? ''
       : forcedFallbackInProp
diff --git a/tsconfig.json b/tsconfig.json
index cb95ae3a..d08e57d1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,6 +8,7 @@
     "jsx": "react",
     "moduleResolution": "node",
     "paths": { "uniforms": ["uniforms/src"], "uniforms-*": ["uniforms-*/src"] },
-    "strict": true
+    "strict": true,
+    "target": "ES6"
   }
 }


Test patch:
diff --git a/packages/uniforms-bridge-json-schema/__tests__/JSONSchemaBridge.ts b/packages/uniforms-bridge-json-schema/__tests__/JSONSchemaBridge.ts
index 6daadf18..b16903e3 100644
--- a/packages/uniforms-bridge-json-schema/__tests__/JSONSchemaBridge.ts
+++ b/packages/uniforms-bridge-json-schema/__tests__/JSONSchemaBridge.ts
@@ -167,6 +167,7 @@ describe('JSONSchemaBridge', () => {
           },
         ],
       },
+      objectWithoutProperties: { type: 'object' },
     },
     required: ['dateOfBirth', 'nonObjectAnyOfRequired'],
   };
@@ -808,6 +809,7 @@ describe('JSONSchemaBridge', () => {
         'arrayWithAllOf',
         'nonObjectAnyOf',
         'nonObjectAnyOfRequired',
+        'objectWithoutProperties',
       ]);
     });
 
@@ -843,6 +845,10 @@ describe('JSONSchemaBridge', () => {
       expect(localBridge.getSubfields()).toEqual(['city', 'state', 'street']);
     });
 
+    it('works when an object does not have properties', () => {
+      expect(bridge.getSubfields('objectWithoutProperties')).toEqual([]);
+    });
+
     it('works on top level when schema does not have properties', () => {
       const localBridge = new JSONSchemaBridge(
         { definitions: schema.definitions, $ref: '#/definitions/lastName' },
diff --git a/packages/uniforms/__tests__/BaseForm.tsx b/packages/uniforms/__tests__/BaseForm.tsx
index 59997d04..110c7fb0 100644
--- a/packages/uniforms/__tests__/BaseForm.tsx
+++ b/packages/uniforms/__tests__/BaseForm.tsx
@@ -1,6 +1,6 @@
 import { ReactWrapper } from 'enzyme';
 import React from 'react';
-import { BaseForm, Bridge, Context } from 'uniforms';
+import { BaseForm, Bridge, Context, useField } from 'uniforms';
 
 import mount from './_mount';
 
@@ -111,6 +111,27 @@ describe('BaseForm', () => {
 
       expect(context).toHaveProperty('schema', schema2);
     });
+
+    it('ignores changes made on first render', () => {
+      function Field() {
+        const [props] = useField('name', {});
+        props.onChange(123);
+        return null;
+      }
+
+      const wrapper = mount<BaseForm<any>>(
+        <BaseForm onChange={onChange} schema={schema}>
+          <Field />
+        </BaseForm>,
+      );
+
+      const context = wrapper.instance().getContext();
+      expect(context).toHaveProperty('changed', false);
+      expect(context).toHaveProperty('changedMap', {});
+
+      expect(onChange).toHaveBeenCalledTimes(1);
+      expect(onChange).toHaveBeenCalledWith('name', 123);
+    });
   });
 
   describe('when changed', () => {
@@ -142,6 +163,16 @@ describe('BaseForm', () => {
       expect(context2).toHaveProperty('changedMap.$.1');
       // @ts-expect-error: Dynamic `changedMap` structure.
       expect(context2.changedMap.$?.[1]).toBeTruthy();
+
+      wrapper.instance().getContext().onChange('$', [1]);
+
+      const context3 = wrapper.instance().getContext();
+      expect(context3).toHaveProperty('changed', true);
+      expect(context3).toHaveProperty('changedMap.$');
+      expect(context3.changedMap.$).toBeTruthy();
+      expect(context3).toHaveProperty('changedMap.$.1');
+      // @ts-expect-error: Dynamic `changedMap` structure.
+      expect(context3.changedMap.$?.[1]).toBeTruthy();
     });
 
     it('autosaves correctly (`autosave` = true)', async () => {
@@ -196,6 +227,14 @@ describe('BaseForm', () => {
       expect(onSubmit).toHaveBeenLastCalledWith(model);
     });
 
+    it('clears autosave correctly', () => {
+      wrapper.setProps({ autosave: true, autosaveDelay: 100 });
+      wrapper.instance().getContext().onChange('a', 1);
+      wrapper.unmount();
+
+      expect(onSubmit).not.toBeCalled();
+    });
+
     it('autosaves correctly (`autosave` = false)', () => {
       wrapper.setProps({ autosave: true });
       wrapper.setProps({ autosave: false });
diff --git a/packages/uniforms/__tests__/connectField.tsx b/packages/uniforms/__tests__/connectField.tsx
index f2703f9f..268fe956 100644
--- a/packages/uniforms/__tests__/connectField.tsx
+++ b/packages/uniforms/__tests__/connectField.tsx
@@ -55,7 +55,7 @@ describe('connectField', () => {
     } as Context<any>,
   };
 
-  const Test = jest.fn(() => null);
+  const Test = jest.fn(props => props.children || null);
 
   beforeEach(() => {
     Test.mockClear();
@@ -108,7 +108,15 @@ describe('connectField', () => {
       expect(onChange).not.toBeCalled();
     });
 
-    it('respects `required`', () => {
+    it('respects `required` (props)', () => {
+      const Field = connectField(Test, { initialValue: true });
+
+      mount(<Field name="another" required />, reactContext);
+
+      expect(onChange).not.toBeCalled();
+    });
+
+    it('respects `required` (schema)', () => {
       const Field = connectField(Test, { initialValue: true });
 
       mount(<Field name="another" />, reactContext);
@@ -184,4 +192,48 @@ describe('connectField', () => {
       },
     );
   });
+
+  describe('when rendered provides correct onChange', () => {
+    it('is defaults to field name', () => {
+      const Field = connectField(Test);
+      const value = 'some value';
+      const wrapper = mount(<Field name="another" />, reactContext);
+      wrapper.find(Test).prop('onChange')(value);
+      expect(onChange).toBeCalledWith('another', value);
+    });
+
+    it('is able to set another field value', () => {
+      const Field = connectField(Test);
+      const value = { subfield: 123 };
+      const wrapper = mount(<Field name="another" />, reactContext);
+      wrapper.find(Test).prop('onChange')(value, 'field');
+      expect(onChange).toBeCalledWith('field', value);
+    });
+  });
+
+  it('works with nested labels', () => {
+    const Field = connectField(Test);
+    const wrapper = mount(
+      <Field name="field" label={null}>
+        <Field name="" />
+        <Field name="subfield" label="Other">
+          <Field name="" />
+        </Field>
+        <Field name="subfield" label={false}>
+          <Field name="">
+            <Field name="" label />
+          </Field>
+        </Field>
+      </Field>,
+      reactContext,
+    );
+
+    expect(wrapper.find(Test).at(0).prop('label')).toBe('');
+    expect(wrapper.find(Test).at(1).prop('label')).toBe('Field');
+    expect(wrapper.find(Test).at(2).prop('label')).toBe('Other');
+    expect(wrapper.find(Test).at(3).prop('label')).toBe('Subfield');
+    expect(wrapper.find(Test).at(4).prop('label')).toBe('');
+    expect(wrapper.find(Test).at(5).prop('label')).toBe('');
+    expect(wrapper.find(Test).at(6).prop('label')).toBe('Subfield');
+  });
 });
diff --git a/packages/uniforms/__tests__/randomIds.ts b/packages/uniforms/__tests__/randomIds.ts
index acfd93c8..1243f9d4 100644
--- a/packages/uniforms/__tests__/randomIds.ts
+++ b/packages/uniforms/__tests__/randomIds.ts
@@ -9,6 +9,11 @@ describe('randomIds', () => {
     expect(randomIds()).toBeInstanceOf(Function);
   });
 
+  it('accepts custom prefix', () => {
+    const generator = randomIds('my-id-generator');
+    expect(generator()).toMatch(/^my-id-generator/);
+  });
+
   it('generate random id', () => {
     const amount = 100;
 
diff --git a/packages/uniforms/__tests__/useField.tsx b/packages/uniforms/__tests__/useField.tsx
new file mode 100644
index 00000000..b037f3a1
--- /dev/null
+++ b/packages/uniforms/__tests__/useField.tsx
@@ -0,0 +1,55 @@
+import React, { ReactNode } from 'react';
+import { BaseForm, useField } from 'uniforms';
+import { JSONSchemaBridge } from 'uniforms-bridge-json-schema';
+
+import mount from './_mount';
+
+describe('useField', () => {
+  const bridge = new JSONSchemaBridge(
+    {
+      type: 'object',
+      properties: {
+        a: { type: 'string' },
+        b: { type: 'object', properties: { c: { type: 'string' } } },
+      },
+    },
+    () => {},
+  );
+
+  function Test(rawProps: {
+    children?: ReactNode;
+    name: string;
+    options?: Parameters<typeof useField>[2];
+  }) {
+    const [props] = useField(rawProps.name, rawProps, rawProps.options);
+    return <>{props.children}</>;
+  }
+
+  it('is a function', () => {
+    expect(useField).toBeInstanceOf(Function);
+  });
+
+  describe('when called with `absoluteName`', () => {
+    it('works on top-level', () => {
+      mount(
+        <BaseForm schema={bridge}>
+          <Test name="a" options={{ absoluteName: true }} />
+          <Test name="b" options={{ absoluteName: true }} />
+          <Test name="b.c" options={{ absoluteName: true }} />
+        </BaseForm>,
+      );
+    });
+
+    it('works nested', () => {
+      mount(
+        <BaseForm schema={bridge}>
+          <Test name="b">
+            <Test name="a" options={{ absoluteName: true }} />
+            <Test name="b" options={{ absoluteName: true }} />
+            <Test name="b.c" options={{ absoluteName: true }} />
+          </Test>
+        </BaseForm>,
+      );
+    });
+  });
+});