I've been struggling for a while with many-to-many associations in a breeze app. I have issues both on the client and server side but for now, I'll just expose my client-side issue. I don't know if the approach I've come up with is correct or not, and I would really like to get feedback from the breeze team on this:
My business model:
public class Request
{
public virtual IList<RequestContact> RequestContacts { get; set; }
}
public class RequestContact
{
public virtual Contact Contact { get; set; }
public virtual Guid ContactId { get; set; }
public virtual Request Request { get; set; }
public virtual Guid RequestId { get; set; }
}
public class Contact
{
public virtual Client Client { get; set; }
public virtual Guid? ClientId { get; set; }
public virtual string Username { get; set; }
}
In the success callback of my getRequest query, I add a contacts
property to the Request and I populate it :
request.contacts = [];
request.requestContacts.forEach(function (reqContact) {
request.contacts.push(reqContact.contact);
});
The view is bound to a contacts array, defined in the controller:
<select ng-multiple="true" multiple class="multiselect" data-placeholder="Select Contacts" ng-change="updateBreezeContacts()" ng-model="request.contacts" ng-options="c as c.username for c in contacts | filter:{clientId: request.client.id} track by c.id"></select>
The controller:
//the collection to which the multiselect is bound:
$scope.contacts = dataService.lookups.contacts;
whenever an item is selected or unselected in the multiselect, this method is called:
$scope.updateBreezeContacts = function () {
//wipe out all the RequestContact entities
$scope.request.requestContacts.forEach(function (req) {
req.entityAspect.setDeleted();
});
//populate the RequestContact based on selected contacts
for (var i = 0; i < $scope.request.contacts.length; i++) {
var requestContact = dataService.createRequestContact($scope.request, $scope.request.contacts[i]);
$scope.request.requestContacts.push(requestContact);
}
where the createRequestContact method of the dataService actually does that:
manager.createEntity('RequestContact', { request: myRequest, contact: myContact});
Use case scenario:
- The request has one selected contact.
- User unselect the contact and then select another one from the list.
- Then she decides to reselect the one that was previously unselected. We now have two selected contacts.
- User hits the save button and the call to saveChanges is made. Breeze sends 3 entities to the server: the first contact with 'Deleted' status, the same contact again with 'Added' status, and finally the other contact that was selected, also with 'Added' status.
Is this what should be done when working with many-to-many associations ?
I actually get a server error ("not-null property references a null or transient value Business.Entities.RequestContact.Request") but before I draw any conclusions, I'd like to know if what I do on the client-side is correct.
The Breeze.js client does not support "many to many" relationships at this time. You will have to expose the junction/mapping table as an entity. There are several other posts on this same topic available.
We do plan to add many-many support in the future. Sorry, but no date yet...
Server-side
You have server-side modeling problems to deal with first. I noted the absence of PKs in my comment to your question. I suggest that you get that working first, before bothering with the client.
Client-side
I have long experience with this kind of scenario. For me the canonical case is a User who can have any number of Roles and the roles s/he has are in the UserRoles table.
The typical UI:
Uh Oh
Many times I have seen people bind the list of all possible roles to a list of
UserRole
entities. This rarely works.Many time I have seen people create and destroy
UserRole
entities as the user clicks the checkbox. This rarely works.Too often I have seen
UserRole
entities added and deleted and added and deleted to the cache. This is usually fatal as the client loses track of whether aUserRole
entity does or does not correspond at this moment to a record in the database.If I read your code correctly, you are making everyone of these mistakes.
Item ViewModel instead
I have had more success when I represented this user's roles as a list of "Item ViewModel" instances and postponed entity manipulation until it was time to save the user's selections.
For our discussion, let's call this object a UserRoleVm. It might be defined as follows in JavaScript
When you build the screen,
populate a list of
UserRoleVm
instances, one for everyRole
set each vm's
role
property with the appropriateRole
entitybind the view to
vm.role.name
set each vm's
userRole
property with the pertinent user'sUserRole
entity if and only if such an entity already existsset vm's
isSelected=true
if the vm has auserRole
and ifvm.userRole.entityAspect.entityState
is not Deleted.bind the vm's
isSelected
to the checkboxNow the user can check and uncheck at will.
I do not create/delete/modify any
UserRole
entity while this is going on. I wait for the UI signal to save (whatever that signal happens to be).During the Save preparation, I iterate over the list of
UserRoleVm
instancesif not checked and no
vm.userRole
, do nothingif not checked and have
vm.userRole
, thenvm.userRole.entityAspect.setDeleted()
. Ifvm.userRole.entityAspect.entityState
is Detached (meaning it was previously in the Added state), setvm.userRole
= null.if checked and no
vm.userRole
, create a newUserRole
and assign it tovm.userRole
if checked and have
vm.userRole
, then ifvm.userRole.entityAspect.entityState
isvm.userRole.entityAspect.rejectChanges()
UserRole
that was "unchecked" but still not saved; how did that happen?), revert by callingvm.userRole.entityAspect.rejectChanges()
Now call
manager.saveChanges()
.If the save succeeds, all is well.
If it fails, the cleanest approach is call
manager.rejectChanges()
. This clears the decks (and discards whatever changes the user made since the last save).Either way, rebuild the list from scratch as we did at the beginning.
Ideally you do not let the user make more changes to user roles until the async save returns either successfully or not.
I'm sure you can be more clever than this. But this approach is robust.
Variation Don't bother with
UserRoleVm.userRole
. Don't carry the existingUserRole
entity in theUserRoleVm
. Instead, refer to the user's cachedUserRole
entities while initializing theUserRoleVm.isSelected
property. Then evaluate the list during save preparation, finding and adjusting the cachedUserRole
instances according to the same logic.Enabling the Save button (update 19 Dec)
Sam asks:
Yes, I can think of several.
Define the
isSelected
property as an ES5 property with get and set methods; inside the set method you signal to the outer VM that theUserRoleVm
instance has changed. This is possible because you must be using an ES5 browser if you've got Angular and Breeze working together.Add an ngClick (or ngChanged) to the checkbox html that binds to a function in the outer vm, e.g.,
Leverage angular's native support for "view changed" detection ("isPristine" I think). I don't usually go this way so I don't know details. It's viable as long as you don't allow the user to leave this screen and come back expecting that unsaved changes to the
UserRoleVm
list have been preserved.The
vm.userRoleClicked
could set avm.hasChanges
property to true. Bind the save button's isEnabled is tovm.hasChanges
. Now the save button lights up when the user clicks a checkbox.As described earlier, the save button click action iterates over the
userRoleVm
list, creating and deletingUserRole
entities. Of course these actions are detected by theEntityManager
.You could get fancier. Your
UserRoleVm
type could record its original selected state when created (userRoleVm.isSelectedOriginal
) and yourvm.userRoleClicked
method could evaluate the entire list to see if any current selected states differ from their original selected states ... and set thevm.hasChanges
accordingly. It all depends on your UX needs.I think I prefer #2; it seems both easiest and clearest to me.
Update 3 February 2014: an example in plunker
I've written a plunker to demonstrate the many-to-many checkbox technique I described here. The readme.md explains all.