Best practice of custom SPA solutions for Salesforce
#angular, #ng, #spa, #salesforce, #bestpractice
Template
Controller
Service
Remote Action
AJAX
JS
APEX
Visualforce component
- Hash link
- No link to folder
- Long synch
+ Quick start
+ Public changes
- Private changes
+ No hashed link
+ Relative path
+ Git
+ Gulp tasks
+ Quick synch
$ git clone https://github.com/vladborsh/angular-sf-seed.git
$ cd angular-sf-seed
$ npm install -g bower
$ npm install
$ npm install -g gulp-cli
$ gulp
...
$ gulp zip-vendor
$ gulp zip-app
var gulp = require('gulp');
var webserver = require('gulp-webserver');
gulp.task('webserver', function() {
gulp.src('app')
.pipe(webserver({
livereload: true,
directoryListing: {
enable: true,
path: 'app'
},
open: true,
https: true
}));
});
/* ... */
gulp.task('default', [ 'webserver']);
Salesforce
Local server
...
#bro
#nonbro
<apex:page showHeader="false" sidebar="false"
controller="FruitsCtrl">
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Fruits</title>
<!-- Vendor scripts -->
<!-- Client scripts -->
</head>
<body ng-app="App">
<!-- Templates storage -->
<c:Templates />
<div class="container-fluid">
<!-- Main router view-->
<div class="animate-opacity" ui-view="main"></div>
</div>
</body>
</html>
</apex:page>
<apex:component >
<script type="text/ng-template" id="Banana.html">
<c:Banana />
</script>
<script type="text/ng-template" id="Opportunity.html">
<c:Opportunity />
</script>
<script type="text/ng-template" id="OpportunityDetails.html">
<c:OpportunityDetails />
</script>
<!-- Other components -->
</apex:component>
<apex:component >
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<!-- Some header -->
</div>
<div class="panel-body">
<!-- Page body -->
</div>
</div>
</div>
</div>
</apex:component>
Fruits.page
Templates.component
Banana.component
Config.$inject = ['$stateProvider', '$urlRouterProvider']
function Config($stateProvider, $urlRouterProvider) {
$stateProvider
.state('banana', {
url: '/',
views: {
"main": {
templateUrl: "Banana.html",
controller: 'BananaCtrl',
controllerAs: 'vm'
}
}
})
/* other states */
$urlRouterProvider.otherwise('/');
};
angular.module('App').config(Config);
<apex:component >
<script type="text/ng-template" id="Banana.html">
<c:Banana />
</script>
<script type="text/ng-template" id="Opportunity.html">
<c:Opportunity />
</script>
<script type="text/ng-template" id="OpportunityDetails.html">
<c:OpportunityDetails />
</script>
<!-- Other components -->
</apex:component>
app.config.js
Templates.component
Part 2
function SfRemote($q, $cookies, $log) {
/* Explicit remote method invocation */
this.do = function(name, arg) {
var deffered = $q.defer();
$log.debug('\n I ~ '+ name);
/* Middleware */
Visualforce.remoting.Manager.invokeAction(name, arg,
function(result,event){
if(event.status){
deffered.resolve(result);
} else {
deffered.reject(event);
}
},
{buffer: true,escape :false}
);
return deffered.promise;
}
};
angular.module('App').service('SfRemote', SfRemote);
sf-remote.srv.js
/* */
this.getAllBananas = function(arg) {
return SfRemote.do( 'FruitsCtrl.getAllBananas',
arg );
}
/* */
banana.srv.js
/* */
this.getAllBananas = function(arg) {
return SfRemote.do( 'FruitsCtrl.getOpenOpportunities',
arg );
}
/* */
opportunity.srv.js
/* */
this.getAllBananas = function(arg) {
return SfRemote.do(
'FruitsCtrl.getAllBananas', arg
);
}
/* */
banana.srv.js
/* */
vm.getAllBananas = function(ownerId, createdDate) {
BananaSrv.getAllBananas(
{
ownerId : ownerId,
createdDate : createdDate
}
).then (
function(data) {
vm.alerts.push({type:data.type, msg:data.msg});
vm.model.bananas = data.items;
}, function(err) {
vm.alerts.push(err);
}
)
}
banana.ctrl.js
sf-remote.srv.js
console.clear();
$scope.processed = true;
$scope.uploadIdPic(jsonObj)
.then (
function (data) {
return $scope.uploadCustomerPic (jsonObj);
}
)
.then (
function (data) {
return $scope.uploadAdditionalIdPic (jsonObj);
}
)
/* ... */
)
.then (
function (data) {
return $scope.clearAndClose ();
}
)
.catch (
function(err) {
console.log(err);
$scope.processed = false;
}
);
Chaining
Part 2
$q.all({
'statuses' : ScreeningCustomerSrv.getCustomerStatuses(),
'sate2CityMap' : ScreeningCustomerSrv.getTerritories(),
/* ... */
'idType2LinkMapping' : ScreeningCustomerSrv.getIdType2LinkMapping(),
})
.then (
function(data) {
_.forEach(data, function(val, key) {
console.log(key, val);
CustomerFct.set(key, val);
})
}
)
.catch(
function(err) {
$scope.model.alerts.push({type:'danger', msg: err.message});
}
);
Parallel
/* ... */
vm.getAllBananas = function(ownerId, createdDate) {
BananaSrv.getAllBananas(
{
ownerId : ownerId,
createdDate : createdDate
}
).then (
function(data) {
vm.alerts.push({type:data.type, msg:data.msg});
vm.model.bananas = data.items;
}, function(err) {
vm.alerts.push(err);
}
)
}
JS Controller
public class BananaService {
/* ... */
public static Map<String,Object> getAllBananas (
Map<String,Object> arg )
{
Map<String,Object> result = new Map<String,Object>();
String ownerId = (String)arg.get('ownerId');
Savepoint sp = Database.setSavepoint();
try {
/* Do somthing useful */
} catch (Exception e) {
Database.rollback(sp);
result = new Map<String,Object> {
'type' => 'error',
'msg' => (e.getMessage())
};
}
return result;
}
}
Apex service
Middleware
function BananaFct() {
var model = {};
return {
get : function(key) {
return model[key];
},
getCopy : function(key) {
return angular.copy(model[key]);
},
getModel : function() {
return model;
},
getModelCopy : function() {
return angular.copy(model);
},
set : function(key, val) {
model[key] = val;
},
setModel : function(_model) {
model = _model;
}
};
};
angular.module('App').service('BananaFct', BananaFct);
Best factory
Part 2
Child controller
Parent ctrl
Factory
Ctrl 1
Ctrl 2
State change
Factory
Part 3
function BananaCtrl (BananaSrv, BananaFct) {
vm .model = {
_BananaFct : BananaFct.getModel(),
lastName : BananaFct.getLastName,
/* ... */
}
/* ... */
}
angular.module('App')
.controller('BananaCtrl', BananaCtrl);
Angular Controller
<input
aria-describedby="basic-addon1"
class="form-control ng-invalid"
name="FirstName"
ng-model="vm.model._BananaFct['FirstName']"
ng-required="true"
type="text" />
<!-- -->
<input
aria-describedby="basic-addon1"
class="form-control ng-invalid"
name="LastName"
ng-model="vm.model.lastName()"
ng-required="true"
type="text" />
Related Component
public with sharing class FruitsCtrl {
public String portalSettings { get; set; }
public String portalSettingsKeySet { get; set; }
public String mainFruit { get; set; }
public String mainFruitIsApple { get; set; }
/* Main initialization */
public FruitsCtrl() {
Map<String,Fruits_Portal_Settings__c> portalSettingsMap
= Fruits_Portal_Settings__c.getall();
if(portalSettingsMap.containsKey('Main Fruit')) {
mainFruit = portalSettingsMap.get('Main Fruit').Value__c;
/* Dummy actions, just for demo */
mainFruitIsApple = JSON.serialize(
!(portalSettingsMap.get('Main Fruit').Value__c == 'Banana'));
}
portalSettings = JSON.serialize(portalSettingsMap);
portalSettingsKeySet = JSON.serialize(portalSettingsMap.keySet());
}
/* ... */
}
<script type="text/javascript">
var portalSettings = {!portalSettings};
var portalSettingsKeySet = {!portalSettingsKeySet};
var mainFruit = '{!mainFruit}';
var mainFruitIsApple = {!mainFruitIsApple};
</script>
public with sharing class MyCtrl {
/* Main initialization */
public MyCtrl() {
/* ... */
portalSettings = JSON.serialize(portalSettingsMap);
portalSettingsKeySet = JSON.serialize(portalSettingsMap.keySet());
}
@RemoteAction
public static Account getAccountByPerc(Integer percent, Account acc) {
/* ... */
return result;
}
@RemoteAction
public static List<Contact> getCustomersForContact (String Id) {
/* ... */
return result;
}
}
MyCtrl.cls
public with sharing class MyExtension{
/* Main initialization */
public MyExtension() {
/* ... */
}
@RemoteAction
public static Contact getExtendedContactInfo (String Id) {
/* ... */
return result;
}
@RemoteAction
public static List<Integer> getSomth (Boolean value) {
/* ... */
return result;
}
}
MyExtension.cls
OpportunityExtension.cls
CustomerExtension.cls
...
public static void applyDiscounts(
Set<ID> opportunityIds,
Decimal discountPercentage)
{
// Query Opportunities and Lines
List<Opportunity> opportunities = // ...
// Update Opportunities and Lines (if present)
SObjectUnitOfWork uow = new SObjectUnitOfWork(SERVICE_SOBJECTS);
for(Opportunity opportunity : opportunities) {
// Apply to Opportunity Amount or Product Lines?
// ...
uow.registerUpdate(opportunity);
// or
uow.registerUpdate(opportunityLine);
}
// Update the database
uow.commitWork();
}
Part 2
public PageReference applyDiscount() {
try {
// Apply discount entered to the current Opportunity
OpportunitiesService.applyDiscounts(
new Set<ID> { standardController.getId() }, DiscountPercentage);
}
catch (Exception e) {
ApexPages.addMessages(e);
}
return ApexPages.hasMessages() ? null : standardController.view();
}
public with sharing class FruitsCtrl {
/* ... */
public FruitsCtrl() {
/* ... */
portalSettings = JSON.serialize(portalSettingsMap);
portalSettingsKeySet = JSON.serialize(portalSettingsMap.keySet());
}
@RemoteAction
public static Map<String,Object> getAllBananas(Map<String,Object> arg) {
return BananaService.getAllBananas(arg);
}
@RemoteAction
public static Map<String,Object> getAllOpportunities (Map<String,Object> arg) {
makeMiddleware(arg);
return OpportunityService.getAllOpportunities(arg);
}
/* ... */
}
Part 3
public class BananaService {
public static Map<String,Object> getAllBananas(
Map<String,Object> arg)
{
Map<String,Object> result = new Map<String,Object>();
String ownerId = (String)arg.get('ownerId');
try {
/* Do something usefull */
} catch (Exception e) {
result = new Map<String,Object> {
'type' => 'error',
'msg' => (e.getMessage())
};
}
return result;
}
}
Controller
Service
public with sharing class FruitsCtrl {
/* ... */
@RemoteAction
public static Map<String,Object> getAllOpportunities (Map<String,Object> arg) {
makeMiddleware(arg);
return OpportunityService.getAllOpportunities(arg);
}
/* ... */
}
public class BananaService {
public static Map<String,Object> getAllBananas (Map<String,Object> arg) {
Map<String,Object> result = new Map<String,Object>();
String ownerId = (String)arg.get('ownerId');
try {
/* Do something usefull */
} catch (Exception e) {
result = new Map<String,Object> {
'type' => 'error',
'msg' => (e.getMessage())
};
}
return result;
}
}
Controller
Service
/* ... */
BananaSrv.getAllBananas(
{
ownerId : ownerId,
createdDate : createdDate
}
).then (
function(data) {
vm.alerts.push({
data
});
vm.model.bananas = data.items;
}, function(err) {
vm.alerts.push(err);
}
)
/* ... */
JS
good parts:
not too good parts :C