How to use fieldsets with Lightning?

As Doug states, I started looking into this, and ended up with a bigger test/demo/etc. than I originally planned. I took the "wrapper" class approach, starting with an AuraEnabled version of FieldSetMember in FieldSetMember.apx:

public class FieldSetMember {

    public FieldSetMember(Schema.FieldSetMember f) {
        this.DBRequired = f.DBRequired;
        this.fieldPath = f.fieldPath;
        this.label = f.label;
        this.required = f.required;
        this.type = '' + f.getType();
    }

    public FieldSetMember(Boolean DBRequired) {
        this.DBRequired = DBRequired;
    }

    @AuraEnabled
    public Boolean DBRequired { get;set; }

    @AuraEnabled
    public String fieldPath { get;set; }

    @AuraEnabled
    public String label { get;set; }

    @AuraEnabled
    public Boolean required { get;set; }

    @AuraEnabled
    public String type { get; set; }
}

This is used in FieldSetController.apxc, which can do things like a) get the names of object types that have field sets and b) get the fields for the field set as a list of FieldSetMember:

public class FieldSetController {

    @AuraEnabled
    public static List<String> getTypeNames() {
        Map<String, Schema.SObjectType> types = Schema.getGlobalDescribe();
        List<String> typeNames = new List<String>();
        String typeName = null;
        List<String> fsNames;
        for (String name : types.keySet()) {
            if (hasFieldSets(name)) {
                typeNames.add(name);        
            }
        }
        return typeNames;
    }

    @AuraEnabled
    public static Boolean hasFieldSets(String typeName) {
        Schema.SObjectType targetType = Schema.getGlobalDescribe().get(typeName);
        Schema.DescribeSObjectResult describe = targetType.getDescribe();
        Map<String, Schema.FieldSet> fsMap = describe.fieldSets.getMap();
        return !fsMap.isEmpty();
    }

    @AuraEnabled
    public static List<String> getFieldSetNames(String typeName) {
        Schema.SObjectType targetType = Schema.getGlobalDescribe().get(typeName);
        Schema.DescribeSObjectResult describe = targetType.getDescribe();
        Map<String, Schema.FieldSet> fsMap = describe.fieldSets.getMap();
        List<String> fsNames = new List<String>();
        for (String name : fsMap.keySet()) {
            fsNames.add(name);
        }
        return fsNames;
    }

    @AuraEnabled
    public static List<FieldSetMember> getFields(String typeName, String fsName) {
        Schema.SObjectType targetType = Schema.getGlobalDescribe().get(typeName);
        Schema.DescribeSObjectResult describe = targetType.getDescribe();
        Map<String, Schema.FieldSet> fsMap = describe.fieldSets.getMap();
        Schema.FieldSet fs = fsMap.get(fsName);
        List<Schema.FieldSetMember> fieldSet = fs.getFields();
        List<FieldSetMember> fset = new List<FieldSetMember>();
        for (Schema.FieldSetMember f: fieldSet) {
            fset.add(new FieldSetMember(f));
        }
        return fset;
    }
}

The controller is used in fsTest.cmp

<aura:component implements="force:appHostable" controller="aotp1.FieldSetController">
    <aura:attribute name="values" type="Object[]"/>
    <aura:attribute name="form" type="Aura.Component[]"/>
    <aura:attribute name="types" type="String[]"/>
    <aura:attribute name="type" type="String" default="NA"/>
    <aura:attribute name="fsNames" type="String[]"/>
    <aura:attribute name="fsName" type="String" default="NA"/>
    <aura:handler name="init" value="{!this}" action="{!c.doInit}" />

    <div class="container">
        <div class="row">
            <div class="section">
                <div class="title">
                    <a href="javascript:void(0);" onclick="{!c.doGetTypeNames}" class="refresh">&#x21bb;</a>
                    SObjects with FieldSets
                </div>
                <dl class="cell list">
                    <aura:iteration items="{!v.types}" var="type">
                        <dd><a href="javscript:void(0);" onclick="{!c.doSelectType}" name="{!type}">{!type}</a></dd>
                    </aura:iteration>
                </dl>
            </div>
            <div class="section">
                <div class="title">
                    FieldSet Names for {!v.type}
                </div>
                <dl class="cell list">
                    <aura:iteration items="{!v.fsNames}" var="name">
                        <dd><a href="javscript:void(0);" onclick="{!c.doSelectFieldSet}" name="{!name}">{!name}</a></dd>
                    </aura:iteration>
                </dl>
            </div>
        </div>
        <div class="row">
            <div class="section">
                <div class="title">
                    Form for {!v.type} with {!v.fsName} fieldset
                </div>
                <div class="controls">
                    <ui:button label="Test Submit" press="{!c.doSubmit}"/>
                </div>
                <div class="cell form">
                    {!v.form}                    
                </div>
            </div>
            <div class="section">
                <div class="title">
                    Data Binding for {!v.type} with {!v.fsName} fieldset
                </div>
                <div class="cell test">
                    <aura:iteration items="{!v.values}" var="item">
                        <div>
                            <span>{!item.name}</span>: <span>{!item.value}</span>
                        </div>
                    </aura:iteration>
                </div>
            </div>
        </div>        
    </div>
</aura:component>

The associated controller, fsTesdtController.js, mainly handles events:

({
    doInit: function(component, event, helper) {
        //helper.getFields(component, event);
    },

    doGetTypeNames: function(component, event, helper) {
        helper.getTypeNames(component, event);
    },

    doSelectType: function(component, event, helper) {
        var type = event.target.getAttribute("name");
        helper.selectType(component, type);
    },

    doSelectFieldSet: function(component, event, helper) {
        var fsName = event.target.getAttribute("name");
        helper.selectFieldSet(component, fsName);
    },

    doSubmit: function(component, event, helper) {
        helper.submitForm(component, event);
    }
})

The helper, fsTestHelper.js, constructs the UI to display the objects with fields sets, the field sets for the selected object, a generated form, and a test area to demonstrate data-binding:

({

    /*
     *  Map the Schema.FieldSetMember to the desired component config, including specific attribute values
     *  Source: https://www.salesforce.com/us/developer/docs/apexcode/index_Left.htm#CSHID=apex_class_Schema_FieldSetMember.htm|StartTopic=Content%2Fapex_class_Schema_FieldSetMember.htm|SkinName=webhelp
     *
     *  Change the componentDef and attributes as needed for other components
     */
    configMap: {
        "anytype": { componentDef: "markup://ui:inputText" },
        "base64": { componentDef: "markup://ui:inputText" },
        "boolean": {componentDef: "markup://ui:inputCheckbox" },
        "combobox": { componentDef: "markup://ui:inputText" },
        "currency": { componentDef: "markup://ui:inputText" },
        "datacategorygroupreference": { componentDef: "markup://ui:inputText" },
        "date": { componentDef: "markup://ui:inputDate" },
        "datetime": { componentDef: "markup://ui:inputDateTime" },
        "double": { componentDef: "markup://ui:inputNumber", attributes: { values: { format: "0.00"} } },
        "email": { componentDef: "markup://ui:inputEmail" },
        "encryptedstring": { componentDef: "markup://ui:inputText" },
        "id": { componentDef: "markup://ui:inputText" },
        "integer": { componentDef: "markup://ui:inputNumber", attributes: { values: { format: "0"} } },
        "multipicklist": { componentDef: "markup://ui:inputText" },
        "percent": { componentDef: "markup://ui:inputNumber", attributes: { values: { format: "0"} } },
        "picklist": { componentDef: "markup://ui:inputText" },
        "reference": { componentDef: "markup://ui:inputText" },
        "string": { componentDef: "markup://ui:inputText" },
        "textarea": { componentDef: "markup://ui:inputText" },
        "time": { componentDef: "markup://ui:inputDateTime", attributes: { values: { format: "h:mm a"} } },
        "url": { componentDef: "markup://ui:inputText" }
    },

    // Adds the component via newComponentAsync and sets the value handler
    addComponent: function(component, facet, config, fieldPath) {
        $A.componentService.newComponentAsync(this, function(cmp) {
            cmp.addValueHandler({
                value: "v.value",
                event: "change",
                globalId: component.getGlobalId(),
                method: function(event) {
                    var values = component.get("v.values");
                    for (var i = 0; i < values.length; i++) {
                        if (values[i].name === fieldPath) {
                            values[i].value = event.getParams().value;
                        }
                    }
                    component.set("v.values", values);
                }
            });

            facet.push(cmp);
        }, config);
    },

    // Create a form given the set of fields
    createForm: function(component, fields) {
        var field = null;
        var cmp = null;
        var def = null;
        var config = null;
        var self = this;

        // Clear any existing components in the form facet
        component.set("v.form", []);

        var facet = component.getValue("v.form");
        var values = [];
        for (var i = 0; i < fields.length; i++) {
            field = fields[i];
            // Copy the config, note that this type of copy may not work on all browsers!
            config = JSON.parse(JSON.stringify(this.configMap[field.type.toLowerCase()]));
            // Add attributes if needed
            config.attributes = config.attributes || {};
            // Add attributes.values if needed
            config.attributes.values = config.attributes.values || {};

            // Set the required and label attributes
            config.attributes.values.required = field.required;
            config.attributes.values.label = field.label;

            // Add the value for each field as a name/value            
            values.push({name: field.fieldPath, value: undefined});

            // Add the component to the facet and configure it
            self.addComponent(component, facet, config, field.fieldPath);
        }
        component.set("v.values", values);
    },

    getTypeNames: function(component, event) {
        var action = component.get("c.getTypeNames");
        action.setParams({})
        action.setCallback(this, function(a) {
            var types = a.getReturnValue();
            component.set("v.types", types);
        });
        $A.enqueueAction(action);        
    },

    selectType: function(component, type) {
        component.set("v.type", type);
        this.getFieldSetNames(component, type);
    },

    getFieldSetNames: function(component, typeName) {
        var action = component.get("c.getFieldSetNames");
        action.setParams({typeName: typeName});
        action.setCallback(this, function(a) {
            var fsNames = a.getReturnValue();
            component.set("v.fsNames", fsNames);
        });
        $A.enqueueAction(action);        
    },

    selectFieldSet: function(component, fsName) {
        component.set("v.fsName", fsName);
        this.getFields(component);
    },

    getFields: function(component) {
        var action = component.get("c.getFields");
        var self = this;
        var typeName = component.get("v.type");
        var fsName = component.get("v.fsName");
        action.setParams({typeName: typeName, fsName: fsName});
        action.setCallback(this, function(a) {
            var fields = a.getReturnValue();
            component.set("v.fields", fields);
            self.createForm(component, fields);
        });
        $A.enqueueAction(action);        
    },

    submitForm: function(component, event) {
        var values = component.get("v.values");
        var s = JSON.stringify(values, undefined, 2);
        alert(s);
    }
})

And a bit of CSS to make it a bit nicer in fsTest.css:

.THIS.container {
    margin: 10px auto;
    width: 100%;
    outline: 1px solid #C0C0C0;
}

.THIS .row {
    width: 100%;
    white-space: nowrap;
}

.THIS .section {
    outline: 1px solid #A0A0A0;
    width: 50%;
    display: inline-block;
    vertical-align: top;
    overflow: none;
    position: relative;
}

.THIS .section .title {
    padding: 4px;
    border-bottom: 1px solid #A0A0A0;
    background: #F0F0F0;
}

.THIS .section .list dd {
    padding: 4px;
    border-bottom: 1px solid #A0A0A0;
    background: #FAFAFA;
}

.THIS .section .cell {
    height: 200px;
    overflow: auto;
}

.THIS .section .controls {
    position: absolute;
    right: 0px;
    top: 0px;
}

.THIS .section .controls .uiButton {
    margin: 1px 5px;
    padding: 2px;
    font-size: 8pt;
}

.THIS .form label {
    width: 160px;
    display: inline-block;
    text-align: right;
    margin: 2px 4px;
}

The component implements force:appHostable, so it can be used in the S1 Mobile App. To use it standalone, here's fsTestApp.app:

<aura:application>
    <aotp1:fsTest/>
</aura:application>

When run, the UI looks like this:

fsTestApp.png

To use it, click on the refresh/reload icon in the upper left. This can take a few seconds as the number of sobjects on a typical org is huge. If you don't have any field sets, you won't see anything in the list. If you do, any object types with field sets are listed. Clicking on the link for the object type will fetch the field sets from the server and display them on the list on the upper right. Clicking on a field set link will generate the form and test listing. Enter values and tab out/hit return to see the values change. Click the Test Submit button to see the values.

Note that there is a bug in ui:inputDate that can occur when using dynamic creation. You'll get a spinner and be blocked.

You can change the DisplayType->component mapping in the configMap in fsTestHelper.js. You could do the mapping otherwise, via code, metadata, etc., if desired.

Let me know if you have any questions. It's not a robust app, but it might provide some ideas on how to approach this.


Let me start with saying thank you for posting this helpful information. It helped me with learning some of Lightning's more advanced features and gave me a jumping off point.

Using @bafuda's component as a starting point, here's another field set form variation that lets one save the record back to Salesforce using an upsert. It supports adding a new record and updating an existing record where the fields to display are driven by a specified field set. This was created for my own learning purposes and recommend using other standard components that provide similar functionality first whenever possible such as the force:recordEdit component.

For more information, see my Lightning Field Set Form Component Blog Post.

Component Markup

<aura:component controller="FieldSetFormController" implements="flexipage:availableForRecordHome,force:hasRecordId,force:hasSObjectName,flexipage:availableForAllPageTypes">
    <aura:attribute name="fieldSetName" type="String" description="The api name of the field set to use from the given object." />

    <aura:attribute name="record" type="SObject" description="The record the fields are bound to that is returned from the server." access="private" />    
    <aura:attribute name="fields" type="Object[]" access="private" />

    <aura:handler name="init" value="{!this}" action="{!c.init}" />
    <aura:handler event="force:refreshView" action="{!c.init}" />

    {!v.body}

    <lightning:button label="Save" onclick="{! c.saveForm }" />
</aura:component>

Component Controller

({
    init: function(cmp, event, helper) {
        console.log('FieldSetFormController.init');

        var fieldSetName = cmp.get('v.fieldSetName');
        var sobjectName = cmp.get('v.sObjectName');
        var recordId = cmp.get('v.recordId');

        if (!fieldSetName) {
            console.log('The field set is required.');
            return;
        }

        var getFormAction = cmp.get('c.getForm');

        getFormAction.setParams({
            fieldSetName: fieldSetName,
            objectName: sobjectName,
            recordId: recordId
        });

        getFormAction.setCallback(this, 
            function(response) {
                var state = response.getState();
                console.log('FieldSetFormController getFormAction callback');
                console.log("callback state: " + state);

                if (cmp.isValid() && state === "SUCCESS") {         
                    var form = response.getReturnValue();
                    cmp.set('v.fields', form.Fields);
                    cmp.set('v.record', form.Record);
                    helper.createForm(cmp);
                }
            }
        );
        $A.enqueueAction(getFormAction);
    },

    saveForm : function(cmp, event, helper) {
        console.log('FieldSetFormController.saveForm');

        var upsertRecordAction = cmp.get('c.upsertRecord');
        var record = cmp.get('v.record');

        if (!record.sobjectType) {
            record.sobjectType = cmp.get('v.sObjectName');
        }

        upsertRecordAction.setParams({
            recordToUpsert: record
        });

        upsertRecordAction.setCallback(this, 
            function(response) {
                var state = response.getState();

                console.log('FieldSetFormController upsertRecordAction callback');
                console.log("callback state: " + state);

                var toastEvent = $A.get("e.force:showToast");

                if (cmp.isValid() && state === "SUCCESS") {

                    toastEvent.setParams({
                        "title": "Success!",
                        "message": "The record has been upserted successfully.",
                        "type": "success"
                    });

                    toastEvent.fire();

                    $A.get('e.force:refreshView').fire();
                }
                else if (state === "ERROR") {
                    var errorMessage = response.getError()[0].message;

                    toastEvent.setParams({
                        "title": "Error",
                        "message": "The record was not saved. Error: " + errorMessage,
                        "type": "error"
                    });

                    toastEvent.fire();
                }
            }
        );
        $A.enqueueAction(upsertRecordAction);
    }
})

Component Helper

({
    /*
     *  Map the Field to the desired component config, including specific attribute values
     *  Source: https://www.salesforce.com/us/developer/docs/apexcode/index_Left.htm#CSHID=apex_class_Schema_FieldSetMember.htm|StartTopic=Content%2Fapex_class_Schema_FieldSetMember.htm|SkinName=webhelp
     *
     *  Change the componentDef and attributes as needed for other components
     */
    configMap: {
        'anytype': { componentDef: 'ui:inputText', attributes: {} },
        'base64': { componentDef: 'ui:inputText', attributes: {} },
        'boolean': {componentDef: 'ui:inputCheckbox', attributes: {} },
        'combobox': { componentDef: 'ui:inputText', attributes: {} },
        'currency': { componentDef: 'ui:inputText', attributes: {} },
        'datacategorygroupreference': { componentDef: 'ui:inputText', attributes: {} },
        'date': {
            componentDef: 'ui:inputDate',
                attributes: {
                    displayDatePicker: true,
                    format: 'MM/dd/yyyy'
                }
            },
        'datetime': { componentDef: 'ui:inputDateTime', attributes: {} },
        'double': { componentDef: 'ui:inputNumber', attributes: {} },
        'email': { componentDef: 'ui:inputEmail', attributes: {} },
        'encryptedstring': { componentDef: 'ui:inputText', attributes: {} },
        'id': { componentDef: 'ui:inputText', attributes: {} },
        'integer': { componentDef: 'ui:inputNumber', attributes: {} },
        'multipicklist': { componentDef: 'ui:inputText', attributes: {} },
        'percent': { componentDef: 'ui:inputNumber', attributes: {} },
        'phone': { componentDef: 'ui:inputPhone', attributes: {} },
        'picklist': { componentDef: 'ui:inputText', attributes: {} },
        'reference': { componentDef: 'ui:inputText', attributes: {} },
        'string': { componentDef: 'ui:inputText', attributes: {} },
        'textarea': { componentDef: 'ui:inputText', attributes: {} },
        'time': { componentDef: 'ui:inputDateTime', attributes: {} },
        'url': { componentDef: 'ui:inputText', attributes: {} }
    },

    createForm: function(cmp) {
        console.log('FieldSetFormHelper.createForm');
        var fields = cmp.get('v.fields');
        var record = cmp.get('v.record');
        var inputDesc = [];

        for (var i = 0; i < fields.length; i++) {
            var field = fields[i];
            var type = field.Type.toLowerCase();

            var configTemplate = this.configMap[type];

            if (!configTemplate) {
                console.log(`type ${ type } not supported`);
                continue;
            }

            // Copy the config so that subsequent types don't overwrite a shared config for each type.
            var config = JSON.parse(JSON.stringify(configTemplate));

            config.attributes.label = field.Label;
            config.attributes.required = field.Required;
            config.attributes.value = cmp.getReference(' v.record.' + field.APIName);
            config.attributes.fieldPath = field.APIName;

            if (!config.attributes['class']) {
                config.attributes['class'] = 'slds-m-vertical_x-small';
            }

            inputDesc.push([
                config.componentDef,
                config.attributes
            ]);
        }

        $A.createComponents(inputDesc, function(cmps) {
            console.log('createComponents');

            cmp.set('v.body', cmps);
        });
    }
})

Component Design

<design:component>
    <design:attribute name="fieldSetName" label="Field Set Name" description="API Name of the field set to use." />
    <design:attribute name="sObjectName" label="Object Name" description="API Name of the Object to use. This only needs to be populated when not on a record detail page." />
</design:component>

Apex Controller Class

public with sharing class FieldSetFormController {
    @AuraEnabled
    public static FieldSetForm getForm(Id recordId, String objectName, String fieldSetName) {
        FieldSetForm form = new FieldSetForm();

        form.Fields = getFields(recordId, objectName, fieldSetName);
        form.Record = getRecord(recordId, objectName, form.Fields);

        return form;
    }

    @AuraEnabled
    public static void upsertRecord(SObject recordToUpsert) {
        upsert recordToUpsert;
    }

    private static List<Field> getFields(Id recordId, String objectName, String fieldSetName) {
        Schema.SObjectType objectType = null;

        if (recordId != null) {
            objectType = recordId.getSobjectType();
        }
        else if (String.isNotBlank(objectName)) {
            objectType = Schema.getGlobalDescribe().get(objectName);
        }

        Schema.DescribeSObjectResult objectDescribe = objectType.getDescribe();
        Map<String, Schema.FieldSet> fieldSetMap = objectDescribe.fieldSets.getMap();
        Schema.FieldSet fieldSet = fieldSetMap.get(fieldSetName);
        List<Schema.FieldSetMember> fieldSetMembers = fieldSet.getFields();

        List<Field> fields = new List<Field>();
        for (Schema.FieldSetMember fsm : fieldSetMembers) {
            Field f = new Field(fsm);

            fields.add(f);
        }

        return fields;
    }

    private static SObject getRecord(Id recordId, String objectName, List<Field> fields) {
        if (recordId == null) {
            Schema.SObjectType objectType = Schema.getGlobalDescribe().get(objectName);
            return objectType.newSObject();
        }

        List<String> fieldsToQuery = new List<String>();
        for (Field f : fields) {
            fieldsToQuery.add(f.APIName);
        }

        Schema.SObjectType objectType = recordId.getSobjectType();
        Schema.DescribeSObjectResult objectDescribe = objectType.getDescribe();
        String objectAPIName = objectDescribe.getName();

        String recordSOQL = 'SELECT ' + String.join(fieldsToQuery, ',') +
                            '  FROM ' + objectAPIName +
                            ' WHERE Id = :recordId';

        SObject record = Database.query(recordSOQL);

        return record;
    }

    public class FieldSetForm {
        @AuraEnabled
        public List<Field> Fields { get; set; }

        @AuraEnabled
        public SObject Record { get; set; }

        public FieldSetForm() {
            Fields = new List<Field>();
        }
    }
}

Field "Wrapper" Apex Class

public class Field {

    public Field(Schema.FieldSetMember f) {
        this.DBRequired = f.DBRequired;
        this.APIName = f.fieldPath;
        this.Label = f.label;
        this.Required = f.required;
        this.Type = String.valueOf(f.getType());
    }

    public Field(Boolean DBRequired) {
        this.DBRequired = DBRequired;
    }

    @AuraEnabled
    public Boolean DBRequired { get;set; }

    @AuraEnabled
    public String APIName { get;set; }

    @AuraEnabled
    public String Label { get;set; }

    @AuraEnabled
    public Boolean Required { get;set; }

    @AuraEnabled
    public String Type { get; set; }
}

Here's a modified version of @Skip Saul's code that works for API version 37. This version allows binding the form field values to the 'record' JS object passed in to the component. As in @Skip Saul's version the form is dynamically created using the fields in the field set.

Example app using the component

<aura:application >
    <aura:attribute name="contact" type="Contact" default="{LastName: 'Smith'}"/>
    <ul>
        <li>LastName: {!v.contact.LastName}</li>
        <li>Email: {!v.contact.Email}</li>
    </ul>

    <c:FieldSetForm 
        fsName="Contact_Field_Set" 
        typeName="Contact" 
        record="{!v.contact}"
    />
    <button onclick="{!c.handleSave}">save</button>
</aura:application>

FieldSetForm.cmp

<aura:component controller="FieldSetCtlr">
    <aura:handler name="init" value="{!this}" action="{!c.init}" />
    <aura:attribute name="record" type="Object" description="The record being edited"/>
    <aura:attribute name="fsName" type="String"/>
    <aura:attribute name="typeName" type="String"/>
    <aura:attribute name="fields" type="Object[]"/>
    <aura:attribute name="form" type="Aura.Component[]"/>
    <aura:attribute name="inputToField" type="Map"/>
    <p>
        {!v.form}
    </p>
</aura:component>

FieldSetFormController.js

({
    init: function(cmp, event, helper) {
        console.log('FieldSetFormController.init');
        var action = cmp.get('c.getFields');
        action.setParams({
            fsName: cmp.get('v.fsName'),
            typeName: cmp.get('v.typeName')
        });
        action.setCallback(this, 
            function(response) { 
                console.log('FieldSetFormController getFields callback');
                var fields = response.getReturnValue();
                cmp.set('v.fields', fields);
                helper.createForm(cmp);
            }
        );
        $A.enqueueAction(action);
    },

    handleValueChange: function(cmp, event, helper) {
        console.log('change');
        var inputToField = cmp.get('v.inputToField');
        var field = inputToField[event.getSource().getGlobalId()];
        var obj = cmp.get('v.record');
        if (!obj[field]) {
            // Have to make a copy of the object to set a new property - thanks LockerService!
            obj = JSON.parse(JSON.stringify(obj));
        }
        obj[field] = event.getSource().get('v.value');
        cmp.set('v.record', obj);
    }
})

FieldSetFormHelper.js

({
    /*
     *  Map the Schema.FieldSetMember to the desired component config, including specific attribute values
     *  Source: https://www.salesforce.com/us/developer/docs/apexcode/index_Left.htm#CSHID=apex_class_Schema_FieldSetMember.htm|StartTopic=Content%2Fapex_class_Schema_FieldSetMember.htm|SkinName=webhelp
     *
     *  Change the componentDef and attributes as needed for other components
     */
    configMap: {
        'anytype': { componentDef: 'ui:inputText', attributes: {} },
        'base64': { componentDef: 'ui:inputText', attributes: {} },
        'boolean': {componentDef: 'ui:inputCheckbox', attributes: {} },
        'combobox': { componentDef: 'ui:inputText', attributes: {} },
        'currency': { componentDef: 'ui:inputText', attributes: {} },
        'datacategorygroupreference': { componentDef: 'ui:inputText', attributes: {} },
        'date': {
            componentDef: 'ui:inputDate',
            attributes: {
                displayDatePicker: true
            }
        },
        'datetime': { componentDef: 'ui:inputDateTime', attributes: {} },
        'double': { componentDef: 'ui:inputNumber', attributes: {} },
        'email': { componentDef: 'ui:inputEmail', attributes: {} },
        'encryptedstring': { componentDef: 'ui:inputText', attributes: {} },
        'id': { componentDef: 'ui:inputText', attributes: {} },
        'integer': { componentDef: 'ui:inputNumber', attributes: {} },
        'multipicklist': { componentDef: 'ui:inputText', attributes: {} },
        'percent': { componentDef: 'ui:inputNumber', attributes: {} },
        'phone': { componentDef: 'ui:inputPhone', attributes: {} },
        'picklist': { componentDef: 'ui:inputText', attributes: {} },
        'reference': { componentDef: 'ui:inputText', attributes: {} },
        'string': { componentDef: 'ui:inputText', attributes: {} },
        'textarea': { componentDef: 'ui:inputText', attributes: {} },
        'time': { componentDef: 'ui:inputDateTime', attributes: {} },
        'url': { componentDef: 'ui:inputText', attributes: {} }
    },

    createForm: function(cmp) {
        console.log('FieldSetFormHelper.createForm');
        var fields = cmp.get('v.fields');
        var obj = cmp.get('v.record');
        var inputDesc = [];
        var fieldPaths = [];
        for (var i = 0; i < fields.length; i++) {
            var field = fields[i];
            var config = this.configMap[field.type.toLowerCase()];
            if (config) {
                config.attributes.label = field.label;
                config.attributes.required = field.required;
                config.attributes.value = obj[field.fieldPath];
                config.attributes.fieldPath = field.fieldPath;
                inputDesc.push([
                    config.componentDef,
                    config.attributes
                ]);
                fieldPaths.push(field.fieldPath);
            } else {
                console.log('type ' + field.type.toLowerCase() + ' not supported');
            }
        }

        $A.createComponents(inputDesc, function(cmps) {
            console.log('createComponents');
            var inputToField = {};
            for (var i = 0; i < fieldPaths.length; i++) {
                cmps[i].addHandler('change', cmp, 'c.handleValueChange');
                inputToField[cmps[i].getGlobalId()] = fieldPaths[i];
            }
            cmp.set('v.form', cmps);
            cmp.set('v.inputToField', inputToField);
        });
    }
})

FieldSetCtlr.cls

public class FieldSetCtlr {

    @AuraEnabled
    public static List<FieldSetMember> getFields(String typeName, String fsName) {
        Schema.SObjectType targetType = Schema.getGlobalDescribe().get(typeName);
        Schema.DescribeSObjectResult describe = targetType.getDescribe();
        Map<String, Schema.FieldSet> fsMap = describe.fieldSets.getMap();
        Schema.FieldSet fs = fsMap.get(fsName);
        List<Schema.FieldSetMember> fieldSet = fs.getFields();
        List<FieldSetMember> fset = new List<FieldSetMember>();
        for (Schema.FieldSetMember f: fieldSet) {
            fset.add(new FieldSetMember(f));
        }
        return fset;
    }

    public class FieldSetMember {

        public FieldSetMember(Schema.FieldSetMember f) {
            this.DBRequired = f.DBRequired;
            this.fieldPath = f.fieldPath;
            this.label = f.label;
            this.required = f.required;
            this.type = '' + f.getType();
        }

        public FieldSetMember(Boolean DBRequired) {
            this.DBRequired = DBRequired;
        }

        @AuraEnabled
        public Boolean DBRequired { get;set; }

        @AuraEnabled
        public String fieldPath { get;set; }

        @AuraEnabled
        public String label { get;set; }

        @AuraEnabled
        public Boolean required { get;set; }

        @AuraEnabled
        public String type { get; set; }
    }
}