<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.0/css/bootstrap.min.css">
<link rel="stylesheet" href="styles.css">
</head>
<body ng-app="app">
<h1>Breeze Many-to-Many with Checkboxes</h1>
<p>See <i>readme.md</i> for instructions and discussion.</p>
<p>Open F12 browser tools and examine the console window to see what is saved.</p>
<!-- THE MAIN CONTENT VIEW -->
<div ng-include="'main.html'"></div>
<!-- Message panel -->
<div ng-include="'messagePanel.html'"></div>
<!-- Libraries -->
<!-- jQuery is required by bootstrap -->
<script src="http://code.jquery.com/jquery-2.1.0.min.js"></script>
<script src="//netdna.bootstrapcdn.com/bootstrap/3.1.0/js/bootstrap.min.js"></script>
<script data-require="angular.js@*" data-semver="1.3.0" src="//code.angularjs.org/1.3.0/angular.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.3.0/angular-animate.min.js"></script>
<script src="https://cdn.rawgit.com/Breeze/breeze.js/master/build/breeze.min.js"></script>
<script src="https://cdn.rawgit.com/Breeze/breeze.js.labs/master/breeze.angular.js"></script>
<script src="https://cdn.rawgit.com/Breeze/breeze.js.labs/master/breeze.metadata-helper.js"></script>
<!-- Application -->
<script src="app.js"></script>
<script src="datacontext.js"></script>
<script src="faking.js"></script>
<script src="main.js"></script>
<script src="messageModule.js"></script>
<script src="metadataFactory.js"></script>
</body>
</html>
(function() {
'use strict';
// Depend upon the breeze/angular shim module
var app = angular.module('app', ['ngAnimate', 'breeze.angular', 'message']);
app.run(['breeze',
function() {/* noop ... just need to inject to get breeze config'd */}
]);
})();
/*** datacontext: the data access service ***/
(function () {
'use strict';
angular.module('app').factory('datacontext',
['$http', '$q', 'faking', 'metadataFactory', datacontext]);
function datacontext($http, $q, faking, metadataFactory) {
var dc, heroPowerMapType, manager;
dc = {
addHero: addHero,
addPower: addPower,
addHeroPowerMap: addHeroPowerMap,
cancel: cancel,
getHeros: getHeros,
getPowers: getPowers,
reset: reset,
revertHeroPowerMaps: revertHeroPowerMaps,
save: save,
saveWillFail: false
};
init();
reset();
return dc;
/*** Implementation ***/
function addHero(name){
return manager.createEntity('Hero', {
name: name
})
}
function addPower(name){
return manager.createEntity('Power', {
name: name
})
}
function addHeroPowerMap(hero, power){
return manager.createEntity('HeroPowerMap', {
heroId: hero.id,
powerId: power.id,
created: new Date()
})
}
// revert all pending (unsaved) changes
function cancel() {
manager.rejectChanges();
}
// All pseudo-data access operation signatures are async
// Get all Heros and their related power (maps)
function getHeros() {
return breeze.EntityQuery.from('Heros')
.expand('powerMaps')
.using(breeze.FetchStrategy.FromLocalCache) // faking; get from cache
.using(manager).execute()
.then(function(resp){return resp.results;});
}
// Get all possible Powers
function getPowers() {
return breeze.EntityQuery.from('Powers')
.using(breeze.FetchStrategy.FromLocalCache) // faking; get from cache
.using(manager).execute()
.then(function(resp){return resp.results;});
}
// private initialization of datacontext
function init(){
// We'll fake the data; we won't actually make a service call
var serviceName = "/your-service-endpoint-here";
manager = new breeze.EntityManager(serviceName);
var meta = manager.metadataStore;
metadataFactory.fillMetadataStore(meta, serviceName);
heroPowerMapType = meta.getEntityType('HeroPowerMap');
faking.init(manager);
}
// Restore data to original (faked) state
function reset() {
return faking.reset();
}
// Undo all pending changes to HeroPowerMaps
function revertHeroPowerMaps() {
manager.getChanges([heroPowerMapType]).forEach(function(entity){
entity.entityAspect.rejectChanges();
})
}
function save() {
// Fail the save deliberately when 'saveWillFail' is true
// else fake the save.
return dc.saveWillFail ?
$q.reject(new Error("Save failed on purpose")) :
faking.save();
}
}
})();
#Presenting a Many-to-Many association with Checkboxes in [Breeze](http://www.breezejs.com) and Angular
[BreezeJS](http://www.breezejs.com) does not (yet)
directly support a many-to-many association between two EntityTypes.
Breeze requires an explicit "map entity" to join the two.
The "map entity" holds a one-to-many relationship to each entity
and can also hold an (optional) payload with properties such as
`isActive` or `mappingCreateDate`.
This deficit is mostly irrelevant to the design challenge illustrated
in this plunker. That challenge is to provide an intuitive UI with which
a user can maintain the M-to-M association. The implementation of the mapping,
whether implicitly or through a "map entity", is besides the point.
The essential quandry is how to present *all the available mappings* so the user
can pick which ones to keep and which to discard ...
as we explain below in the "HeroVm" section.
In this example, an AngularJS UI presents one entity (the "hero") and all possible
related mappings to the other type (the "super powers" that a "hero" can have)
as a collection of checkboxes.
The implementation is based on ideas discused in
[this StackOverflow post](http://stackoverflow.com/questions/20638851/breeze-many-to-many-issues-when-saving).
## Why so many files?
Yes there are a lot of files.
Only two of them (*main.html* and *main.js*)
concern the UI issues at the core of this plunker.
The others set up the application, style it, and manage the supporting sample data.
These files would be reusable in a different example exploring another design challenge
... and you may see them again :-)
## The Story
There are heros and super powers.
In this UI you can assign super powers to the heros, one hero at a time,
by selecting a hero from the combobox and checking/unchecking the power
that the hero should (or should not) have.
Press "Save" to make the selections *permanent*.
Press "Cancel" to revert unsaved selections to their last "saved" state.
Press "Reset" to reset the "database" to its original state.
>There is no actual database in this example as persistence is unnecessary
when demonstrating the technique for presenting all available mappings
on screen.
>
>The example data are faked in the
*faking.js* script which also fakes the "save", simulating what Breeze would do if
the Breeze `EntityManager` actually saved the changes remotely.
We describe the design challenge and our solution in the "HeroVm" section below.
Before we get there, we need to know about the Model, View, and ViewModel.
## Model (and metadata)
`Hero` and `Power` are the two main types.
They have a one-to-many relationship with a mapping entity, `HeroPowerMap`.
We don't need to define model types in JavaScript. We can let Breeze do that
for us from metadata.
The *metadataFactory.js* script defines the metadata for
these three types and fills a `MetadataStore` with metadata (see `fillMetadataStore`).
This metadata factory script is a simple example of
[writing metadata by hand](href="http://www.breezejs.com/documentation/metadata-by-hand").
## View and ViewModel
The *index.html* is the host page. It specifies the CSS and JavaScript for the
example.
An angular `ng-app` directive bootstraps the "app" module defined in
*app.js*. The application UI appears in the small <div> with the
`ng-include` attribute which identifies *main.html* as the View.
That *main.html* identifies `main` (defined in *main.js*)
as the supporting ViewModel/Controller
using the "Controller as" syntax that we favor.
## HeroVm: the special sauce
We have a design problem.
We can't simply present the existing mapping entities (the `HeroPowerMap` entities)
with checkboxes in front of them
because these entities only represent the powers that *the hero already has*.
We need a check list of *all available powers*, not just the hero's current powers.
For each hero we create an "Item ViewModel" for every available power mapping.
In *main.js* it's called a `powerMapVm` and it has two properties:
* the `Power`
* whether that power is currently selected, initially whether the hero has that power
Each hero needs its own collecton of such `powerMapVms` so
we create yet another "Item ViewModel", the `heroVm`, with two properties:
* `hero`
* `powerMapVms`
Then we give the `main` controller a collection of these `heroVm` objects
(`vm.heroVms`) and bind to that collection in the *main.html* View.
## How many ViewModels are there?
You may have tripped over the following binding in *main.html*:
ng-repeat="mapVm in vm.currentHeroVm.powerMapVms"
LOL! There really are 3 nested ViewModels
main (the controller's solitary 'vm')
heroVm (many in 'vm.heroVms')
powerMapVm (many in 'heroVm.powerMapVms' )
## Saving changes
The user can check and uncheck powers at will. She can even switch to other
heros and change their powers.
***The app does not alter the state of any entities*** while the user is making
such decisions.
This is a crucial implementation choice. Long experience has taught us that
we should **not** try to add and delete mapping entities
while the user is checking and unchecking powers.
It is too hard, too messy, and too complicated to keep pace with the user.
The best, cleanest approach is to wait until the user saves. Only then
do we reason over the user's selections (see `applySelectionsToHeroPowerMaps`),
decide which mapping entities
we should create and which we should delete, and
ask Breeze to save these mapping entity changes.
If the save succeeds, all of the `heroVms` remain in a good state and
the selected powers correspond to the saved `HeroPowerMap` instances.
The save could fail ... as when we lose the connection to the server.
If the save fails, we revert the pending `HeroPowerMap`
changes that we made just before save.
This rollback strategy preserves the user's current selections
(athough there is now a disparity between those selections and the
hero's actually-current powers).
She can try the save again when connectivity is restored.
>You can see this recovery process in action by clicking
the "Simulate save failure" checkbox and trying to save.
## Summary
It is a bit tricky to present all the choices and adjust the persisted many-to-many
data accordingly. The keys to success are:
1. Bind to "Item ViewModels", not to the entities themselves
2. Postpone resolution of the user's selections until the moment of save.
Happy programming!
/*
* Creates BreezeJS Metadata describing the Hero-Power-HeroPowerMap Model
*
* Usage:
* // assume you include this service as 'metadataFactory'
*
* // create a new EntityManager
* var manager = new breeze.EntityManager("your-service-endpoint");
*
* // get the MetadataStore from the manager and fill it
* var store = manager.metadataStore;
* metadataFactory.fillMetadataStore(store);
*
*/
(function(){
'use strict';
angular.module('app').factory('metadataFactory', factory);
function factory(){
var addType, DATE, DT, helper, ID;
// The metadata definition service
return {
fillMetadataStore: fillMetadataStore
};
/*** Implementation ***/
function fillMetadataStore(metadataStore, serviceName) {
init(metadataStore, serviceName);
addHero();
addPower();
addHeroPowerMap();
}
function addHero() {
addType({
name: 'Hero',
dataProperties: {
id: { type: ID },
name: { max: 50, nullOk: false },
},
navigationProperties: {
powerMaps: { type: 'HeroPowerMap', hasMany: true }
}
});
}
function addPower() {
addType({
name: 'Power',
dataProperties: {
id: { type: ID },
name: { max: 50, nullOk: false },
},
});
}
function addHeroPowerMap() {
addType({
name: 'HeroPowerMap',
// key is set on the client, not auto-generated
autoGeneratedKeyType: breeze.AutoGeneratedKeyType.None,
dataProperties: {
heroId: { type: ID, key: true },
powerId: { type: ID, key: true },
created:{ type: DATE, nullOk: false },
},
navigationProperties: {
hero:'Hero',
power:'Power'
}
});
}
// Initialize the metdataFactory with convenience fns and variables
function init(metadataStore, serviceName){
var store = metadataStore; // the metadataStore that we'll be filling
// namespace of the corresponding classes on the server
var namespace = 'Model'; // don't really need it here
// 'Identity' is the default key generation strategy for this app
var keyGen = breeze.AutoGeneratedKeyType.Identity;
// Breeze Labs: breeze.metadata-helper.js
// https://github.com/Breeze/breeze.js.labs/blob/master/breeze.metadata-helper.js
// The helper reduces data entry by applying common conventions
// and converting common abbreviations (e.g., 'type' -> 'dataType')
helper = new breeze.config.MetadataHelper(namespace, keyGen);
helper.addDataService(store, serviceName);
// addType - make it easy to add the type to the store using the helper
addType = function (type) { return helper.addTypeToStore(store, type); };
// DataTypes we'll be using
DT = breeze.DataType;
DATE = DT.DateTime;
ID = DT.Int32;
}
}
})();
/*** The Main ViewModel (Controller) ***/
(function() {
'use strict';
angular.module('app')
.controller('Main', ['$log', '$q', 'datacontext','messageService', Main]);
function Main($log, $q, datacontext, messageService) {
var vm = this;
vm.cancel = cancel;
vm.currentHeroVm,
vm.heroVms = [];
vm.reset = reset;
vm.save = save;
vm.saveWillFail = false;
init();
/*** Implementation ***/
var sendMessage = messageService.addMessage; // convenience method
function init() {
vm.heroVms = [];
vm.powers = [];
// Get Heros and Powers asynchronously in parallel
var hPromise = datacontext.getHeros();
var pPromise = datacontext.getPowers();
$q.all([hPromise, pPromise])
.then(function(values){
vm.heros = values[0];
vm.powers = values[1];
createHeroVms();
vm.currentHeroVm = vm.heroVms[0];
});
}
function createHeroVms() {
vm.heroVms = vm.heros.map(createHeroVm);
}
function createHeroVm(hero) {
var powerHash = createHeroPowerHash(hero);
var powerMapVms = vm.powers.map(function(power){
return {
power: power,
selected: !!powerHash[power.id]
};
});
var heroVm = {
hero: hero,
powerMapVms: powerMapVms
};
return heroVm;
}
// powerHash is a dictionary of hero's current powers
function createHeroPowerHash(hero) {
var powerHash = {};
hero.powerMaps.forEach(function(map){
powerHash[map.powerId]=map;
});
return powerHash;
}
function failed(error){
var message = error ? error.message || error : 'unknown error';
$log.error(message);
$log.error(error);
sendMessage("Something went wrong: "+ message + '.');
}
// revert selections to match current state of the model
function cancel() {
createHeroVms();
// reposition on VM for current here
var hero = vm.currentHeroVm.hero;
vm.currentHeroVm =
vm.heroVms.filter(function(vm){return vm.hero === hero})[0];
sendMessage('Canceled your power selections since the last save.');
}
function reset() {
datacontext.reset().then(success).catch(failed);
function success() {
init();
sendMessage('Reset to initial sample data values.');
}
}
function save() {
vm.heroVms.forEach(applySelectionsToHeroPowerMaps)
datacontext.saveWillFail = vm.saveWillFail;
datacontext.save().then(success).catch(saveFailed);
function success() {
sendMessage('Saved your power selections.');
}
function saveFailed(error){
$log.error('Save failed');
datacontext.revertHeroPowerMaps();
failed(error);
}
// creates and deletes HeroPowerMap entities
function applySelectionsToHeroPowerMaps(heroVm) {
var hero = heroVm.hero;
var mapVms = heroVm.powerMapVms;
var powerHash = createHeroPowerHash(hero);
mapVms.forEach(function(mapVm){
var map = powerHash[mapVm.power.id];
if (mapVm.selected) { // user selected this power
if (!map) { // if no existing map, create one
map = datacontext.addHeroPowerMap(hero, mapVm.power);
}
} else { // user de-selected this power
if (map) { // if map exists, delete it
map.entityAspect.setDeleted();
}
}
});
}
}
}
})();
<!-- The Main View -->
<div data-ng-controller="Main as vm">
<!-- Twitter Bootstrap styling -->
<div class="container">
<div class="row">
<div class="col-xs-12">
<button data-ng-click="vm.save()" class="btn btn-primary" rel="tooltip"
title="Save selected powers for all heros">Save</button>
<button data-ng-click="vm.cancel()" class="btn btn-warning" rel="tooltip"
title="Revert selected powers of all heros to
their last saved states">Cancel</button>
<button data-ng-click="vm.reset()" class="btn btn-danger" rel="tooltip"
title="Reset the 'database' and all selections to the
initial sample state">Reset</button>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="vm.saveWillFail"> Simulate save failure
</label>
</div>
</div>
</div>
<hr/>
<div class="row">
<div class="col-xs-4">
<select class="form-control"
ng-options="hvm.hero.name for hvm in vm.heroVms"
ng-model="vm.currentHeroVm"></select>
</div>
<div class="col-xs-8">
<ul class="list-unstyled">
<li ng-repeat="mapVm in vm.currentHeroVm.powerMapVms">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mapVm.selected"> {{mapVm.power.name}}
</label>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
/*** Faking data and save for this example ***/
/*
* Creates heros, super powers, and the mappings between them
*/
(function () {
'use strict';
angular.module('app')
.factory('faking',['$http', '$log', '$q', 'metadataFactory', faking]);
function faking($http, $log, $q, metadataFactory) {
var heroType, heroPowerMapType, manager,
nextId, powerType;
return {
init: init,
reset: reset,
save: save
}
/*** Implementation ***/
function addHero(name){
return manager.createEntity(heroType, {
name: name
})
}
function addPower(name){
return manager.createEntity(powerType, {
name: name
})
}
function addHeroPowerMap(hero, power){
return manager.createEntity(heroPowerMapType, {
heroId: hero.id,
powerId: power.id,
created: new Date()
})
}
function init(entityManager){
manager = entityManager;
var meta = manager.metadataStore;
heroType = meta.getEntityType('Hero');
powerType = meta.getEntityType('Power');
heroPowerMapType = meta.getEntityType('HeroPowerMap');
nextId = 1; // for faking creation of new Ids
}
function fixIds() {
// fake how save would substitute realIds for fakeIds
var idMap= {};
// fix PKs of new Heros and Powers
var changes = manager.getChanges([heroType, powerType]);
changes.forEach(function (entity){
if (entity.id < 0){ // new; has temp id
var newId = nextId++;
idMap[entity.id] = newId;
entity.id = newId;
}
})
// fix PK of HeroPowerMap
changes = manager.getChanges([heroPowerMapType]);
changes.forEach(function (map){
if (map.heroId < 0){ // new; has temp id
map.heroId = idMap[map.heroId];
}
if (map.powerId < 0){ // new; has temp id
map.powerId = idMap[map.powerId];
}
if (map.heroId === undefined || map.powerId === undefined){
throw new Error("Bad Ids for HeroPowerMap "+
JSON.stringify(map));
}
})
}
function loadFakeData() {
var heros = ['Superman', 'Flash', 'Wonder Woman',
'Spiderman', 'Hulk', 'Captain America']
.map(function(name){
return addHero(name);
});
var powers = ['Super strong', 'Super fast', 'ESP',
'Hypersensitivity']
.map(function(name){
return addPower(name);
});
// Superman
addHeroPowerMap(heros[0], powers[0]); // strong
addHeroPowerMap(heros[0], powers[1]); // fast
// Flash
addHeroPowerMap(heros[1], powers[1]); // fast
// Wonder Woman
addHeroPowerMap(heros[2], powers[0]); // strong
addHeroPowerMap(heros[2], powers[2]); // ESP
// Spiderman
addHeroPowerMap(heros[3], powers[0]); // strong
addHeroPowerMap(heros[3], powers[1]); // fast
addHeroPowerMap(heros[3], powers[3]); // spidey sense
// Hulk
addHeroPowerMap(heros[4], powers[0]); // strong
// Captain America's got zip for super powers
// Fake save
return save();
}
function reset() {
nextId = 1;
manager.clear();
return loadFakeData();
}
function save() {
// Fake save by simulating the result of a breeze.saveChanges
try {
fixIds();
// log the saved changes
$log.info('\n*** FAKE SAVE ***')
var changes = manager.getChanges();
var cache = manager.exportEntities(changes, false /* exclude metadata */);
$log.info(cache);
$log.info('**********\n')
manager.acceptChanges();
return $q.when(true);
} catch (e) {
return $q.reject(e);
}
}
}
})();
body {
padding: 2em;
}
/* Animations */
.animate-if {
background:white;
border:1px solid black;
padding:10px;
}
.animate-if.ng-enter, .animate-if.ng-leave {
-moz-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s;
-o-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s;
-webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s;
transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s;
}
.animate-if.ng-enter,
.animate-if.ng-leave.ng-leave-active {
opacity:0;
}
.animate-if.ng-leave,
.animate-if.ng-enter.ng-enter-active {
opacity:1;
}
/*
* Module for displaying messages that may be "sent" from anywhere in the app.
*
* Defines a 'messageService' to which you can add, get, and clear messages AND
* a default 'messagePanel' controller/ViewModel that can support
* displaying and clearing the 'messageService' messages.
*
* You'd put these in separate files in a real application.
*/
(function() {
angular.module('message', [])
.factory('messageService', service)
.controller('messagePanel', ['messageService', panel]);
/*** Implementation ***/
function service() {
var messages = [];
var msgId = 1;
return {
addMessage: addMessage,
addTestMessage: addTestMessage,
clearMessages: clearMessages,
messages: messages
};
function addMessage(msg) {
messages.push(msgId++ + '. ' + msg);
}
function addTestMessage() {
addMessage("Added test message at " + new Date().toTimeString());
}
function clearMessages() {
messages.length = 0;
}
}
function panel(errorService) {
var vm = this;
vm.clearMessages = clearMessages;
vm.getMessages = getMessages;
vm.hasMessages = hasMessages;
function clearMessages() {
errorService.clearMessages();
}
function getMessages() {
return errorService.messages;
}
function hasMessages() {
return errorService.messages.length > 0;
}
}
})();
<!-- Message Panel View -->
<div ng-controller="messagePanel as vm">
<div class="panel panel-info animate-if" ng-if="vm.hasMessages()">
<div class="panel-heading">
<span class="glyphicon glyphicon-flag"></span> <strong>Messages</strong>
<a style="float: right;" class="btn btn-primary btn-xs" role="button"
ng-click="vm.clearMessages()">Clear</a>
</div>
<div class="panel-body">
<p ng-repeat="msg in vm.getMessages()">{{msg}}</p>
</div>
</div>
</div>