The reason to use the URL hacks was to leverage the layout mechanism in Visualforce: the layout could be used for this "new child" case as well as for later editing. Using a custom controller and a Visualforce page (that does work in Lightning Experience) means you have to re-create the layout programmatically which has two problems:
- When customers want to modify the layout, they can't just use the WYSIWYG layout editor but have to also configure the Visualforce and controller somehow.
- It isn't a DRY way to develop.
I've posted the page I'm using as a stopgap for now below. This is not a good solution - if you have a better one please add your answer.
It doesn't solve either of the above problems, but does allow the calling code to decide the SObject and fields displayed so the configuration moves to the parent page controller (that in my case already had a configuration mechanism). If I had a few days to spare, I'd base the layout on an Apex metadata call to get the layout (though some caching might be needed if that turned out to be slow).
Controller:
/**
* Controller that provides an alternative to the LKID/retURL hack as that doesn't work in Lightning Experience.
* Driven by URL parameters that include API name/value pairs that can set values such as the parent Id.
* Caller defines which object is shown and which fields - sadly doesn't use the layouts.
* Presently only supports the "New" case but could be extended to support updating.
*/
public with sharing class GenericDetailController {
public static final String SOB_TYPE_PARAM = 'sobType';
public static final String DISPLAY_PARAM = 'display';
public static final String REQUIRED_FIELDS_PARAM = 'required';
public static final String READ_ONLY_FIELDS_PARAM = 'readOnly';
public static final String RET_URL_PARAM = 'retURL';
public SObjectType type {get; private set;}
public String[] fields {get; private set;}
public Map<String, Boolean> requireds {get; private set;}
public Map<String, Boolean> editables {get; private set;}
public SObject sob {get; private set;}
private transient DescribeSObjectResult describe;
public DescribeSObjectResult getDescribe() {
if (describe == null) describe = type.getDescribe();
return describe;
}
public GenericDetailController() {
Map<String, String> params = ApexPages.currentPage().getParameters();
// Required param
type = Schema.getGlobalDescribe().get(params.get(SOB_TYPE_PARAM));
Rights r = new Rights(getDescribe());
if (!r.c) {
ApexPages.addMessage(new ApexPages.Message(
ApexPages.Severity.ERROR,
'You do not have the access rights to create this object'
));
return;
}
sob = type.newSObject();
// Required param
fields = params.get(DISPLAY_PARAM).split(',');
// Optional param
Set<String> requiredFields = StringUtil.splitCsvsWithTrimSet(params.get(REQUIRED_FIELDS_PARAM));
requireds = new Map<String, Boolean>();
for (String field : fields) requireds.put(field, requiredFields.contains(field));
// Optional param
Set<String> readOnlyFields = StringUtil.splitCsvsWithTrimSet(params.get(READ_ONLY_FIELDS_PARAM));
editables = new Map<String, Boolean>();
for (String field : fields) editables.put(field, !readOnlyFields.contains(field));
// Any parameter that matches the lower case field API name will be set
Set<String> allFields = getDescribe().fields.getMap().keySet();
for (String key : params.keySet()) {
if (allFields.contains(key.toLowerCase())) {
// Could do type conversion but generally here just Id values or String so no need
Object value = params.get(key);
sob.put(key, value);
}
}
}
public PageReference save() {
try {
upsert sob;
return retUrl();
} catch (DmlException e) {
ApexPages.addMessages(e);
return null;
}
}
public PageReference cancel() {
return retUrl();
}
private PageReference retUrl() {
// Required param
return new PageReference(ApexPages.currentPage().getParameters().get(RET_URL_PARAM));
}
}
Page:
<apex:page controller="GenericDetailController">
<apex:form >
<apex:sectionHeader title="{! describe.label } Edit" subtitle="New {! describe.label }"/>
<apex:pageMessages />
<apex:pageBlock title="{! describe.label } Edit" mode="edit" rendered="{! sob != null }">
<apex:pageBlockButtons >
<apex:commandButton value="Save" action="{! save }"/>
<apex:commandButton value="Cancel" action="{! cancel }"/>
</apex:pageBlockButtons>
<apex:pageBlockSection title="Information" columns="1">
<apex:repeat var="f" value="{! fields }">
<apex:inputField value="{! sob[f] }" required="{! requireds[f] }" rendered="{! editables[f] }"/>
<apex:outputField value="{! sob[f] }" rendered="{! !editables[f] }"/>
</apex:repeat>
</apex:pageBlockSection>
</apex:pageBlock>
</apex:form>
</apex:page>
Parent controller:
public PageReference addAbc() {
...
PageReference pr = Page.GenericDetail;
Map<String, String> parameters = pr.getParameters();
parameters.put(GenericDetailController.SOB_TYPE_PARAM, String.valueOf(childSobType));
parameters.put(GenericDetailController.DISPLAY_PARAM, asTightCsv(fields));
parameters.put(GenericDetailController.READ_ONLY_FIELDS_PARAM, asTightCsv(readOnlys));
parameters.put(GenericDetailController.REQUIRED_FIELDS_PARAM, asTightCsv(requireds));
// Pre-populate this
parameters.put(String.valueOf(referencingChildField), parent.Id);
// Want to return to here
parameters.put(GenericDetailController.RET_URL_PARAM, currentPageUrl);
return pr;
}
private static String asTightCsv(SObjectField[] fields) {
String[] strings = new String[] {};
for (SObjectField f : fields) strings.add(String.valueOf(f));
return String.join(strings, ',');
}