Convert oneOf to enum/enumNames for form rendering with react-jsonschema-form
authorFrank Bessou <frank.bessou@logilab.fr>
Tue, 11 Apr 2017 15:15:27 +0200
changeset 104 80bf81f6463c
parent 103 930f8bb45e22
child 105 ebe07e45cd21
Convert oneOf to enum/enumNames for form rendering with react-jsonschema-form
src/components/Entity.js
src/components/Form.js
test/index.js
--- a/src/components/Entity.js	Mon Apr 10 11:07:49 2017 +0200
+++ b/src/components/Entity.js	Tue Apr 11 15:15:27 2017 +0200
@@ -1,5 +1,5 @@
 import React from 'react';
-import Form from 'react-jsonschema-form';
+import {FormWrapper} from './Form';
 import {Link} from 'react-router';
 
 import {isEmpty} from 'lodash/lang';
@@ -374,7 +374,7 @@
         return (
             <div>
                 {errors}
-                <Form {...this.state} onSubmit={this.onSubmit} />
+                <FormWrapper {...this.state} onSubmit={this.onSubmit} />
             </div>
         );
     }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/components/Form.js	Tue Apr 11 15:15:27 2017 +0200
@@ -0,0 +1,71 @@
+import React from 'react';
+import Form from 'react-jsonschema-form';
+import {cloneDeep} from 'lodash/lang';
+
+export class ReactJsonSchemaAdapterFactory {
+    // Sanitize schema in order to render it with
+    // react-jsonschema-form Form component
+    fromSchema(schema) {
+        return this.adaptRecursively(cloneDeep(schema))
+    }
+
+    adaptRecursively(obj) {
+        if (Array.isArray(obj.oneOf) && obj.oneOf.length > 0) {
+            try {
+                const {enumNames, enumValues} = this.getEnumFromOneOf(obj);
+                obj.enumNames = enumNames;
+                obj.enum = enumValues;
+                delete obj.oneOf;
+            } catch (error){
+                // Can't be resolved as enum
+            }
+        }
+
+        for (let i in obj) {
+            const type = typeof obj[i];
+            if (type === 'object' && obj[i] !== null) {
+                this.adaptRecursively(obj[i]);
+            }
+        }
+        return obj;
+    }
+
+    getEnumFromOneOf(schema) {
+        const enumValues = [];
+        const enumNames = [];
+        for (let i in schema.oneOf) {
+            const constant = this.schemaToConstant(schema.oneOf[i], i);
+            enumValues.push(constant.value);
+            enumNames.push(constant.name);
+        }
+        return {enumNames, enumValues};
+    }
+
+    // Try to resolve oneOf case as a constant.
+    // Returns {name, value} on success else throws an Error
+    schemaToConstant(oneOfCase, id) {
+        let value;
+        if (oneOfCase.hasOwnProperty('const')) {
+            value = oneOfCase.const;
+        } else if (Array.isArray(oneOfCase.enum) && oneOfCase.enum.length === 1) {
+            value = oneOfCase.enum[0];
+        } else {
+            throw new Error('Cannot extract constant from schema');
+        }
+        const name = oneOfCase.title || (typeof value === 'string' ? value : `Option #${id}`);
+        return {name, value};
+    }
+}
+
+// Wrap react-jsonschema-form Form component to handle
+// oneOf + const schemas.
+export function FormWrapper(props) {
+    const adapterFactory = new ReactJsonSchemaAdapterFactory();
+    const schema = adapterFactory.fromSchema(props.schema);
+    return <Form {...props} schema={schema} />;
+}
+
+FormWrapper.propTypes = {
+    schema: React.PropTypes.object.isRequired,
+    adapterFactory: React.PropTypes.object,
+}
--- a/test/index.js	Mon Apr 10 11:07:49 2017 +0200
+++ b/test/index.js	Tue Apr 11 15:15:27 2017 +0200
@@ -14,6 +14,7 @@
 import {ActionLink, ActionsDropDown, CollectionItemLink} from '../src/components/BaseViews';
 import {EntityMeta} from '../src/components/Entity';
 import {wrapEntityData} from "../src/jsonaryutils";
+import {ReactJsonSchemaAdapterFactory} from '../src/components/Form';
 
 const userEditionSchema = {
     "$ref": "#/definitions/CWUser",
@@ -525,3 +526,209 @@
         });
     });
 });
+
+describe('ReactJsonSchemaAdapterFactory', () => {
+    const adapterFactory = new ReactJsonSchemaAdapterFactory();
+
+    describe('fromSchema', () => {
+        context('given a schema with oneOf including schemas which can be inferred to constants', () => {
+            it('should return an adapted schema', () => {
+                const schema = {
+                    oneOf: [{
+                        const: 'FIRST_VALUE',
+                    }, {
+                        enum: ['SECOND_VALUE'],
+                        title: 'SCHEMA_TITLE',
+                    }, {
+                        const: {valid: false},
+                    }],
+                };
+                const expectedAdapter = {
+                    enum: ['FIRST_VALUE', 'SECOND_VALUE', {valid: false}],
+                    enumNames: ['FIRST_VALUE', 'SCHEMA_TITLE', 'Option #2'],
+                };
+
+                const adapter = adapterFactory.fromSchema(schema);
+
+                expect(adapter).to.deep.equal(expectedAdapter);
+            });
+        });
+
+        context('given a schema with oneOf including one schema which cannot be inferred to constant', () => {
+            it('should return the same schema', () => {
+                const schema = {
+                    oneOf: [{
+                        const: 'FIRST_VALUE',
+                    }, {
+                        enum: ['SECOND_VALUE', 'THIRD_VALUE'],
+                    }],
+                };
+
+                const adapter = adapterFactory.fromSchema(schema);
+
+                expect(adapter).to.deep.equal(schema);
+            });
+        });
+    });
+
+    describe('adaptRecursively', () => {
+
+        beforeEach( () => {
+            sinon.stub(adapterFactory, 'getEnumFromOneOf');
+            sinon.stub(adapterFactory, 'adaptRecursively');
+            adapterFactory.adaptRecursively.callThrough();
+        });
+
+        afterEach( () => {
+            adapterFactory.getEnumFromOneOf.restore();
+            adapterFactory.adaptRecursively.restore();
+        });
+
+        it ('should call recursively on nested arrays and object', () => {
+            const schema = {a: '', b: 1, c: null, d: {e: [{f:1}, {g:2}, null]}};
+
+            adapterFactory.adaptRecursively(schema);
+
+            const expectedArgs = [
+                schema,
+                schema.d,
+                schema.d.e,
+                schema.d.e[0],
+                schema.d.e[1],
+            ];
+
+            for (let args of expectedArgs) {
+                assert.calledWith(adapterFactory.adaptRecursively, args);
+            }
+
+            assert.callCount(adapterFactory.adaptRecursively, 5);
+        });
+
+        context('getEnumFromOneOf returns values', () => {
+
+            it ('should replace oneOf by enumNames/enum', () => {
+                adapterFactory.getEnumFromOneOf.returns({enumNames: ['A'], enumValues: ['a']});
+                const schema = {oneOf: [{},{}], title: 'TITLE'};
+
+                const adapter = adapterFactory.adaptRecursively(schema);
+
+                expect(adapter).to.deep.equal({enum: ['a'], enumNames: ['A'], title: 'TITLE'});
+            });
+        });
+
+        context('getEnumFromOneOf thrown', () => {
+
+            it ('should not replace oneOf when getEnumFromOneOf throws', () => {
+                adapterFactory.getEnumFromOneOf.throws();
+                const schema = {oneOf:[]};
+
+                const adapter = adapterFactory.adaptRecursively(schema);
+
+                expect(adapter.oneOf).to.exist;
+                expect(adapter.enum).to.not.exist;
+                expect(adapter.enumNames).to.not.exist;
+            });
+        });
+    });
+
+    describe('getEnumFromOneOf', () => {
+
+        beforeEach( () => {
+            sinon.stub(adapterFactory, 'schemaToConstant');
+        });
+
+        afterEach( () => {
+            adapterFactory.schemaToConstant.restore();
+        });
+
+        it ('should return as many enum values as the number of alternative schemas', () => {
+            const schema = {oneOf: [{},{},{}]};
+            adapterFactory.schemaToConstant.returns({name: 'A', value: 'a'});
+            const expectedLength = 3;
+
+            const {enumNames, enumValues} = adapterFactory.getEnumFromOneOf(schema);
+
+            expect(enumNames).lengthOf(expectedLength);
+            expect(enumValues).lengthOf(expectedLength);
+        })
+    });
+
+    describe('schemaToConstant', () => {
+
+        beforeEach( () => {
+            sinon.spy(adapterFactory, 'schemaToConstant');
+        });
+
+        afterEach( () => {
+            adapterFactory.schemaToConstant.restore();
+        });
+
+        const CONST_VALUE = "THE_CONST_VALUE";
+        const ENUM_VALUE = "THE_ENUM_VALUE";
+
+        it('should get value from const when it exists', () => {
+            const schema = {const: CONST_VALUE};
+
+            const {value} = adapterFactory.schemaToConstant(schema, 0);
+
+            expect(value).to.equal(CONST_VALUE);
+        });
+
+        it('should get value from const when const and enum are present', () => {
+            const schema = {enum: [ENUM_VALUE], const: CONST_VALUE};
+
+            const {value} = adapterFactory.schemaToConstant(schema, 0);
+
+            expect(value).to.equal(CONST_VALUE);
+        });
+
+        it('should get value from enum when it exists and there is no const', () => {
+            const schema = {enum: [ENUM_VALUE]};
+
+            const {value} = adapterFactory.schemaToConstant(schema, 0);
+
+            expect(value).to.equal(ENUM_VALUE);
+        });
+
+        it('should throw when enum is not an array', () => {
+            const schema = {enum: ENUM_VALUE};
+
+            expect(() => adapterFactory.schemaToConstant(schema, 0)).to.throw();
+
+        });
+
+        it('should get name from title property when it exists', () => {
+            const schema = {enum: ['ANYTHING'], title: 'THE_TITLE'};
+
+            const {name} = adapterFactory.schemaToConstant(schema, 0);
+
+            expect(name).to.equal('THE_TITLE');
+        });
+
+        context('the schema does not contain a title', () => {
+
+            context('the inferred value is a string', () => {
+
+                it('should get name from inferred value', () => {
+                    const schema = {enum: ['THE_VALUE']};
+
+                    const {name} = adapterFactory.schemaToConstant(schema, 0);
+
+                    expect(name).to.equal('THE_VALUE');
+                });
+            });
+
+            context('the inferred value is not a string', () => {
+
+                it('build the name from the id', () => {
+                    const id = 7;
+                    const schema = {enum: [{color: 'red'}]};
+
+                    const {name} = adapterFactory.schemaToConstant(schema, id);
+
+                    expect(name).to.match(RegExp(`^[^0-9]*${id}[^0-9]*$`));
+                });
+            });
+        });
+    });
+});