Adding Social Shopping Features to EPiServer Commerce with EPiServer Community
At the recent EPiServer Tech Forum in Stockholm, I showed an example of how to build a social shopping feature in EPiServer Commerce. The feature enabled a logged-in user to add comments about a product in EPiServer Commerce.
Today I would like to walk you through the code to achieve this, but before you start you should probably read the article about creating a CMS site with Commerce and Community I recently published.
Community Entities
The EPiServer Community system is largely based on entities. Examples of entities in Community are blogs, forums, clubs and contacts.
Community Functions
EPiServer Community also facilitates functions or actions to be performed on entities, providing they implement the relevant interface. Examples of functions are rate, comment, tag, visit etc.
Commerce Products
EPiServer Commerce stores its product information in a catalog in the Commerce Manager database.
Exposing a Commerce Product as a Community Entity
Before we can build the user interface for adding a comment to a product, we need to create the infrastructure to support it. That means creating something which allows a product in EPiServer Commerce to be exposed as an entity in EPiServer Community. As part of that we will also need to store instances of those product entities.
The in-built entities that ship with EPiServer Community are stored in dedicated database tables. Whilst we could do this, it feels a little over the top as our product entity will not contain much information. With that said, I have decided to store our entity in the Dynamic Data Store (DDS).
In order to store Community entities in the DDS, you need to create a class that implements the EPiServer.Common.Data.IEntityProvider interface (EPiServer.Common.Data assembly). The class is also expected to adhere to the singleton design pattern and should have a public static method called GetProviderInstance, which returns the singleton instance of the provider:
1: public class DdsEntityProvider : IEntityProvider
2: {
3: private static DdsEntityProvider _instance;
4:
5: public static IEntityProvider GetProviderInstance()
6: {
7: if (_instance == null)
8: {
9: _instance = new DdsEntityProvider();
10: }
11:
12: return _instance;
13: }
14:
15: #region IEntityProvider Members
16:
17: public IEntity AddEntityInstance(IEntity entity)
18: {
19: throw new NotImplementedException();
20: }
21:
22: public IEntity GetEntityInstance(Type type, int id)
23: {
24: throw new NotImplementedException();
25: }
26:
27: public object GetEntityInstance(Type type, System.Data.Common.DbDataReader reader)
28: {
29: throw new NotImplementedException();
30: }
31:
32: public SupportedOperations GetSupportedOperations(Type type)
33: {
34: throw new NotImplementedException();
35: }
36:
37: public void RemoveEntityInstance(IEntity entity)
38: {
39: throw new NotImplementedException();
40: }
41:
42: public void UpdateEntityInstance(IEntity entity)
43: {
44: throw new NotImplementedException();
45: }
46:
47: #endregion
48: }
We now need to implement the IEntityProvider interface. As we will need to create or obtain an existing store in the DDS for each entity type, we can create a nice little helper method to do this for us:
1: private static DynamicDataStore GetOrCreateStore(Type t)
2: {
3: return DynamicDataStoreFactory.Instance.GetStore(t) ??
4: DynamicDataStoreFactory.Instance.CreateStore(t);
5: }
We only want this entity provider to able to handle entities that derive from the DdsStoredEntity base class described below. Lets add a helper method to ensure this, which can be called from all IEntityProvider implementation methods:
1: private void EnsureDdsStoredEntityType(Type t)
2: {
3: if (!t.IsSubclassOf(typeof(DdsStoredEntity)))
4: {
5: throw new NotSupportedException(string.Format("The type '{0}' must derive from type {1} to be handled by type '{2}'", t.FullName, typeof(DdsStoredEntity).FullName, typeof(DdsEntityProvider).FullName));
6: }
7: }
Now we can implement the IEntityProvider methods:
1: public EPiServer.Common.IEntity AddEntityInstance(EPiServer.Common.IEntity entity)
2: {
3: EnsureDdsStoredEntityType(entity.GetType());
4: GetOrCreateStore(entity.GetType()).Save(entity);
5: return entity;
6: }
7:
8: public IEntity GetEntityInstance(Type type, int id)
9: {
10: EnsureDdsStoredEntityType(type);
11:
12: Guid uniqueId = EntityProviderHandler.Instance.GetEntityUniqueID(new EntityReference(type, id));
13: DynamicDataStore store = DynamicDataStoreFactory.Instance.GetStore(type);
14:
15: if (store != null)
16: {
17: return store.Load(uniqueId) as IEntity;
18: }
19:
20: return null;
21: }
22:
23: public object GetEntityInstance(Type type, System.Data.Common.DbDataReader reader)
24: {
25: throw new NotImplementedException();
26: }
27:
28: public SupportedOperations GetSupportedOperations(Type type)
29: {
30: EnsureDdsStoredEntityType(type);
31: return SupportedOperations.Get | SupportedOperations.Add | SupportedOperations.Update | SupportedOperations.Remove;
32: }
33:
34: public void RemoveEntityInstance(EPiServer.Common.IEntity entity)
35: {
36: EnsureDdsStoredEntityType(entity.GetType());
37: GetOrCreateStore(entity.GetType()).Delete(entity);
38: }
39:
40: public void UpdateEntityInstance(EPiServer.Common.IEntity entity)
41: {
42: EnsureDdsStoredEntityType(entity.GetType());
43: GetOrCreateStore(entity.GetType()).Save(entity);
44: }
45:
In addition to the standard IEntityProvider methods, we also need a method to allow finding of entities by properties other than the Community ID property:
1: public IEnumerable<TEntity> FindEntityInstance<TEntity>(string propertyName, object propertyValue) where TEntity : DdsStoredEntity
2: {
3: DynamicDataStore store = DynamicDataStoreFactory.Instance.GetStore(typeof(TEntity));
4:
5: if (store != null)
6: {
7: return store.Find<TEntity>(propertyName, propertyValue);
8: }
9:
10: return new List<TEntity>();
11: }
In this scenario it is useful to have a base class that all entities to be stored in the DDS are derived from. This gives two things:
- Takes care of the entity identity management
- Inherits from the Community system FrameworkEntityBase class to ensure all derived classes inherit the correct Community interfaces to make them commentable, ratable etc.
1: public abstract class DdsStoredEntity : FrameworkEntityBase, IDynamicData
2: {
3: protected DdsStoredEntity() : base(-1)
4: {
5: }
6:
7: #region IDynamicData Members
8:
9: public EPiServer.Data.Identity Id
10: {
11: get;
12: set;
13: }
14:
15: #endregion
16:
17: public override int ID
18: {
19: get
20: {
21: if (Id != null)
22: {
23: return (int)Id.StoreId;
24: }
25:
26: return -1;
27: }
28: protected set
29: {
30:
31: }
32: }
33:
34: public override Guid UniqueID
35: {
36: get
37: {
38: return Id.ExternalId;
39: }
40: }
41: }
Now that we have an entity base class and a DDS entity provider, we can create the actual Commerce product entity class:
1: [DataContract]
2: public class CommerceProductEntity : DdsStoredEntity
3: {
4: public override string[] CacheKey
5: {
6: get { return new[] { typeof(CommerceProductEntity).FullName, ID.ToString() }; }
7: }
8:
9: [DataMember]
10: public int CatalogEntryId
11: {
12: get;
13: set;
14: }
15:
16: public Entry CatalogEntry
17: {
18: get
19: {
20: return CatalogContext.Current.GetCatalogEntry(this.CatalogEntryId);
21: }
22: }
23: }
The important things to note about this class are:
- It is decorated with the System.Runtime.Serialization.DataContractAttribute class. This tells the DDS to only save the properties marked with the DataMember attribute. This is required as we don’t want the ‘ID’ and ‘UniqueID’ properties on the DdsStoredEntity base class to be saved because:
- These properties reflect the constituent value parts of the Identity ‘Id’ property which is saved anyway
- The ‘ID’ property will cause the DDS to throw an exception if you try and save it
- The ‘CacheKey’ property must be provided for use in the Community cache system
- The ‘CatalogEntryId’ property is decorated with the System.Runtime.Serialization.DataMember class for the reasons mentioned above
- The ‘CatalogEntry’ property is not marked with DataMember as it derives its value from the ‘CatalogEntryId’ property and therefore doesn’t need to be stored. Indeed the value of the property is a Mediachase.Commerce.Catalog.Objects.Entry instance which is stored in the Commerce Catalog.
The last bit of the puzzle is to implement a handler for the Commerce product entity as is standard with all Community entities:
1: public class CommerceProductEntityHandler : FrameworkFactoryBase
2: {
3: private static CommerceProductEntityHandler _instance;
4:
5: public static CommerceProductEntityHandler Instance
6: {
7: get
8: {
9: if (_instance == null)
10: {
11: _instance = new CommerceProductEntityHandler();
12: }
13:
14: return _instance;
15: }
16: }
17:
18: public virtual CommerceProductEntity GetCommerceProductEntity(int id)
19: {
20: return EntityProviderHandler.Instance.GetEntityProvider(
21: typeof(CommerceProductEntity)).GetEntityInstance(typeof(CommerceProductEntity), id)
22: as CommerceProductEntity;
23: }
24:
25: public virtual CommerceProductEntity AddCommerceProductEntity(CommerceProductEntity entity)
26: {
27: CommerceProductEntity savedEntity =
28: (CommerceProductEntity)EntityProviderHandler.Instance.GetEntityProvider(
29: entity.GetType()).AddEntityInstance(entity);
30:
31: UpdateEntity(entity, entity.ID);
32: return savedEntity;
33: }
34:
35: public virtual CommerceProductEntity UpdateCommerceProductEntity(CommerceProductEntity entity)
36: {
37: EntityProviderHandler.Instance.GetEntityProvider(entity.GetType()).UpdateEntityInstance(entity);
38: UpdateEntity(entity);
39: return entity;
40: }
41:
42: public virtual void RemoveCommerceProductEntity(CommerceProductEntity entity)
43: {
44: EntityProviderHandler.Instance.GetEntityProvider(entity.GetType()).RemoveEntityInstance(entity);
45: RemoveEntity(entity);
46:
47:
48: }
49:
50: public virtual CommerceProductEntity GetCommerceProductEntityByCatalogEntryId(int catalogEntryId)
51: {
52: return ((DdsEntityProvider)DdsEntityProvider.GetProviderInstance()).FindEntityInstance<CommerceProductEntity>("CatalogEntryId", catalogEntryId).FirstOrDefault();
53: }
54: }
The CommerceProductEntityHandler implements a similar set of methods as the other handlers in EPiServerCommunity, such as the BlogHander, along with an additional method to find an entity based on the Commerce Catalog Entry Id.
Adding and displaying comments for a product
We can now go ahead and implement some UI code to make use of the new functionality. I’ll skip the html and boiler plate code here for brevity and concentrate on the actual code that makes use of our shiny new Commerce product entity.
If there is a user control which allows the user to write a comment in a text area called ‘CommentBox’ and its click event has been wired up to a method called AddCommentButton_Click, then the code to add a comment for the current product would look like this:
1: protected void AddCommentButton_Click(object sender, EventArgs e)
2: {
3: // Create a new comment with the user input
4: // connected to the currently logged in user
5: Comment comment = new Comment(CommunityEntity,
6: string.Format("Comment for {0}",
7: CommunityEntity.CatalogEntry.Name),
8: Server.HtmlEncode(this.CommentBox.Text),
9: new UserAuthor(CurrentUser));
10:
11: // Mark it with the "ProductId" attribute so it can be searched for exlusively
12: comment.SetAttributeValue<int>("ProductId", CommunityEntity.CatalogEntryId);
13:
14: // Add save it
15: CommentHandler.Instance.AddComment(comment);
16: CommentBox.Text = "";
17: }
There are a couple of properties being used here which also need to be coded. The ‘CommunityEntity’ property represents the Community entity for the current product:
1: protected CommerceProductEntity CommunityEntity
2: {
3: get
4: {
5: if (_communityEntity == null)
6: {
7: // See if an entity already exists for this product
8: _communityEntity =
9: CommerceProductEntityHandler
10: .Instance
11: .GetCommerceProductEntityByCatalogEntryId(this.CommerceCatalogEntryId);
12:
13: if (_communityEntity == null)
14: {
15: // Create a new entity for this product
16: _communityEntity = new CommerceProductEntity()
17: {
18: CatalogEntryId = this.CommerceCatalogEntryId
19: };
20:
21: // And save it
22: CommerceProductEntityHandler
23: .Instance
24: .AddCommerceProductEntity(_communityEntity);
25: }
26: }
27:
28: return _communityEntity;
29: }
30: }
Here we see if a CommerceProductEntity instance already exists for the current product by querying the CommerceProductEntityHandler. If there is not an existing entity then we create a new one and save it through the handler.
The CommerceCatalogEntryId property is the id of the currently shown product and is set by the control’s parent page / control:
1: public int CommerceCatalogEntryId
2: {
3: get;
4: set;
5: }
We then want some code to show existing comments for the current product:
1: protected void SetupCommentsListing()
2: {
3: int totalCount = 0;
4: CommentSortOrder sortOrder = new CommentSortOrder(CommentSortField.Created, SortingDirection.Descending);
5:
6: var comments = CommentHandler.Instance.GetComments(
7: CommunityEntity, // The entity to get the comments for
8: null, // The user - get comments for all users
9: DateTime.MinValue, // The minimum creation date for the comment
10: DateTime.MaxValue, // The maximum creation date for the comment
11: EntityStatus.Approved, // The status of the comments - approved only
12: 1, // The page - 1st page only
13: 5, // The number of items per page - 5
14: out totalCount, // The total number of comments satifying the query
15: sortOrder); // The sort order - descending
16:
17: // Reverse the comments so the oldest is first
18: CommentsRepeater.DataSource = comments.Reverse();
19: CommentsRepeater.DataBind();
20: }
We call the SetupCommentListing method from the control’s OnPreRender method.
Showing all product comments for a user
It would also be cool to be able to show a logged in user all the comments they have made about products in the catalog. For this we need another user control. Once again I will skip the html and boiler plate code here and concentrate on the code that deals with retrieving the comments:
1: protected void SetupCommentsListing()
2: {
3: IAuthor author = CurrentAuthor;
4:
5: if (author != null)
6: {
7: // Query by current author
8: CommentQuery query = new CommentQuery();
9: query.Author = new AuthorCriterion();
10: query.Author.Name = new StringCriterion();
11: query.Author.Name.Value = CurrentAuthor.Name;
12:
13: // and by approved status
14: query.Status = new EntityStatusCriterion();
15: query.Status.Value = EntityStatus.Approved;
16:
17: // and comment must have "ProductId" attribute
18: IntegerCriterion productIdCriterion = new IntegerCriterion();
19: productIdCriterion.Operator = ComparisonOperator.GreaterThan;
20: productIdCriterion.Value = 0;
21:
22: query["ProductId"] = productIdCriterion;
23:
24: var commments = CommentHandler.Instance.GetQueryResult(query);
25:
26: if (commments.Count > 0)
27: {
28: CommentsRepeater.DataSource = commments;
29: CommentsRepeater.DataBind();
30: }
31:
32: // Don't want to cache is this scenario
33: QueryHandler.Instance.RemoveQueryResultCache(query);
34: }
35: }
In this method we create a CommentQuery and set the Author and Status properties with valid criteria. We also create a criterion for the ProductId attribute as we only want to show comments about products here. If we didn’t do this then we would get all approved comments for the current user regardless of what the comments related to.
Configuration
We need to register the DdsEntityProvider with the Community system in the site’s web.config file. This allows you to obtain an entity provider for a certain entity type as demonstrated in the CommerceProductEntityHandler class:
1: <configuration>
2: <episerver.common>
3: <sites>
4: <site siteId="*">
5: <entity>
6: <providers>
7: <add name="DdsEntityProvider" type="EPiServer.Business.Commerce.Sample.CommunityIntegration.DdsEntityProvider, EPiServer.Business.Commerce.Sample" />
8: </providers>
9: <supportedTypes>
10: <add type="EPiServer.Business.Commerce.Sample.CommunityIntegration.CommerceProduct.CommerceProductEntity, EPiServer.Business.Commerce.Sample" provider="DdsEntityProvider" />
11: </supportedTypes>
12: </entity>
13: </site>
14: </sites>
15: </episerver.common>
16: </configuration>
Once the site is up and running, we need to register the “ProductId” attribute used on the Comment class. This is done in Community Admin Mode –> Attributes –> Create Attribute:
The full source code for this sample can be found here in a zip file. The zip also includes changes to existing Commerce Sample user controls and pages which use the new user controls for commenting products.
To get this sample up and running you will need to do the following:
- Create a new Commerce & Community CMS Site as explained in this article.
- Extract the contents of the zip file to the new site’s folder. You will be prompted to overwrite some existing files with those in the zip.
- Open the CommerceSample project from the site’s root folder using Visual Studio and recompile the solution.
- Update the site’s web.config file with the entity registration xml which can be found in the entitysection.xml file in the zip. The <entity> section should already exist in web.config so it’s just a matter of replacing that section with the file’s contents.
- Browse to the site, login as user “admin”, password “store”, then right click and select the Admin Mode menu item
- In Community Admin Mode, add the ProductId attribute as described above.
- Restart the site. The Community cache system will not have the new attribute in memory so a site restart is needed.
You should now be able to add comments to products and also see all your product comments on the My Account page.
Happy commenting!
Comments