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">↻</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:

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.