How to customize the Order Website view models using resource strings, ASP.NET MVC view models, ViewBag, and Knockout.js view models.
Overview
AtomiaStore makes data available to views in a few different forms:
- Resource strings: Static (localized) text. See Resource Strings and Localization
- ASP.NET MVC view models: E.g. forms. See below.
- ViewBag: Mainly data used across multiple views. See Customizing the Order Website Order Flow for an example.
- Knockout.js view models: JavaScript view models for interactive behavior populated via JSON rendered on page load, e.g. the cart. See below.
ASP.NET MVC View Models
Standard ASP.NET MVC view models are used mostly for handling forms. They let us leverage the built-in functionality for form validation and model binding.
The view model for each page type in the order flow can be extended or replaced:
- Atomia.Store.AspNetMvc.Models.AccountViewModel
- Atomia.Store.AspNetMvc.Models.CheckoutViewModel
- Atomia.Store.AspNetMvc.Models.DomainsViewModel
- Atomia.Store.AspNetMvc.Models.ProductListingViewModel
The related Atomia.Store.AspNetMvc.Models.ProductListingModel can also be extended.
AccountViewModel and the CheckoutViewModel are abstract classes with the following default implementations:
- Atomia.Store.AspNetMvc.Models.DefaultAccountViewModel
- Atomia.Store.AspNetMvc.Models.DefaultCheckoutViewModel
Defining a New View Model
In the following example the default AccountViewModel is extended with a sub-form for an optional Professional Survey of your customers, in addition to the default main contact and billing contact forms.
- If you used the
startnewtheme.ps1script to bootstrap your theme, you should already have aModelsdirectory. Add a new file in this directory with the classesSurveyModelandMyAccountViewModel:
using Atomia.Store.AspNetMvc.Models;
using Atomia.Store.Core;
using Atomia.Web.Plugin.Validation.ValidationAttributes; // 1.
namespace MyTheme.Models
{
public class SurveyModel : ContactData // 2.
{
public override string Id { get { return "Survey"; } } // 3.
[AtomiaRequired("Common,ErrorEmptyField")] // 4.
public string JobTitle { get; set; }
public string Department { get; set; } // 5.
}
public class MyAccountViewModel : DefaultAccountViewModel // 6.
{
public MyAccountViewModel() : base()
{
this.Survey = new SurveyModel(); // 7.
}
public SurveyModel Survey { get; set; } // 8.
}
}
- The
Atomia.Web.Plugin.Validationlibrary has some specific model validation attributes that properly handle error messages as defined in theme resource files. It is not available by default in the bootstrapped theme, so you need to add the reference to the assemblyMyTheme\Lib\Atomia.Web.Plugin.Validation.dll - The subform that you will use to collect the data subclasses
Atomia.Store.Core.ContactDataso you can later access it an add it to the order you place in Atomia Billing. - A
ContactDataimplementation must have anIdproperty. It is convenient to have it be the same as the name of the subform property in theAccountViewModel, in this case Survey. - In this example, you do not want to require the customer to fill in the survey, but if they choose to do so, you want to require that they supply a job title. You also re-use an error message that already exists in the default
resxfiles. Departmentis an optional field.- Since you just want to add an extra form, and not completely reimplement the
AccountViewModel, you sub-class the existingDefaultAccountViewModel. - For the new
SurveyModelto be available in the view you need to instantiate it whenMyAccoutViewModelis instantiated. - Since you did not want to require the user to supply survey data, configure this by not requiring the
SurveyModelsubform. If you POST the form without any of theSurveyModelfields, the ASP.NET MVC model binder will ignore the subform and not generate any validation errors for it, but if you POST any of the fields from the subform the model validation will be triggered.
You have now defined the new view model, but it is not used in your Razor views yet.
There are two steps to adding the new model to the view.
- Registering the model with the dependency resolver.
- Setting the model as the
@modelin the view or appropriate partial view.
Registering the Model With the Dependency Resolver
AtomiaStore leverages Unity and the ASP.NET MVC DependencyResolver to make many parts of the application replacable and extendable via dependency injection or service location.
By default the DefaultAccountViewModel is registered to be provided when an instance of AccountViewModel is needed.
You can override this registration either programmatically in App_Start\UnityConfig.cs or with configuration in the unity section of Web.config.
How to do it in UnityConfig:
public class UnityConfig
{
public static void RegisterComponents(UnityContainer container)
{
container.RegisterType<AccountViewModel, MyTheme.Models.MyAccountViewModel>();
}
}
How to do it in Web.config:
<unity xmlns="http://schemas.microsoft.com/practices/2010/unity">
...
<alias alias="AccountViewModel" type="Atomia.Store.AspNetMvc.Models.AccountViewModel, Atomia.Store.AspNetMvc" />
<alias alias="MyAccountViewModel" type="MyTheme.Models.MyAccountViewModel, MyTheme" />
...
<container>
...
<register type="AccountViewModel" mapTo="MyAccountViewModel" />
...
</container>
</unity>
Using the Model in the View
Having prepared the new view model you should now present it to the user by rendering it as a form in the view.
When you have a new view model implementation there are a few things to take into account when deciding how to use it in the views:
- Is the view model a standalone implementation or a sub-class of the default model?
- How extensive are the changes?
- Are there any existing partials that can contain the changes or does the “page” view need to be overridden?
In this case you have sub-classed the DefaultAccountViewModel instead of re-implementing your own AccountViewModel from scratch. This means you do not need to change any of the views that are using DefaultAccountViewModel as their view model since your view model is still of that type. You have also only added to the default implementation, not overridden or hidden any of the existing members, so existing partial views that use the model properties can be left as is.
The Account/Index.cshtml view has a couple of pre-defined extension points that can be used to add things without a need to override the whole view to add new partials: Account/_ExtraForms.cshtml and Account/_ExtraScripts.cshtml.
In this example you want to keep as much of the Default theme as possible to get the benefit of future updates to it, so you decide that you can add your new form to the Account/_ExtraForms.cshtml. This is done by adding the file Themes/MyTheme/Views/Account/_ExtraForms.cshtml with the following markup:
<div class="pro-survey">
// 2.
<h4>@Html.CommonResource("ProfessionalSurvey")</h4>
// 3.
@Html.FormRowFor(m => m.Survey.JobTitle, Html.CommonResource("JobTitle") + ":", true) // 4.
@Html.FormRowFor(m => m.Survey.Department, Html.CommonResource("Department") + ":", false) // 5.
</div>
Index.cshtmlrenders the_ExtraForms.cshmlpartial with the whole view model like this:Html.RenderPartial("_ExtraForms", Model);Use your newly defined class to strongly type the view so that you can access
Surveyand its members.- Wrap the new markup in a
divto be able to style it, perhaps by floating it to one side or something else. - Add a title for your new sub-form that matches the titles for Contact Info and Billing Address. You also need to add the resource string
"ProfessionalSurvey"toApp_GlobalResources/MyTheme/MyThemeCommon.resxand any localizations you need. - Use an HTML helper from the Default theme to render the input field and label. It renders label, input field, and validation messages in the standard markup used for form fields in the Default theme. The last boolean argument
truemarks this field as required. You also need to add"JobTitle"toApp_GlobalResources/MyTheme/MyThemeCommon.resxfor the label. - The same as 4, except this field is not required.
You should now have a basic form added for your survey. It will also have client-side as well as backend validation of the required JobTitle field. However, you are not completely fulfilling your initial requirements yet since the survey is currently not optional and the JobTitle field always will be required. In the next section you will see how this can be fixed by working with the knockout.js view model for the page.
Knockout.js View Models
AtomiaStore uses knockout.js view models for more interactive elements of the user interface. As with the backend code, these can also be reused and extended in different ways.
Continuing with your Professional Survey example from above, you want to make the entire sub-form optional, similar to how it is optional fill in the Default Billing Contact form.
The existing knockout view models on the Account page are all instantiated in the Themes/Default/Views/Account/_Scripts.cshtml partial view. You can see there that there is an existing Atomia.ViewModels.AccountModel that is instantiated as Atomia.VM.account. This is a knockout view model that is used for showing or hiding the Billing Contact form and to control if the fields from that form are posted to the server or not, and to customize some fields depending on if the customer type is "individual" or "company".
For your new Professional Survey form you have the choice to either extend the existing Atomia.VM.account model or to add a separate knockout view model. In general, whether you choose to extend an existing knockout view model or create a new one depends on what you want to accomplish.
In the below example you will do both. You start with a basic knockout view model that is independent of the existing functionality and then make it dependent on some of the existing account model’s functionality and change to an extension model.
In both cases you put your new knockout view model code in Themes/MyTheme/Scripts/mytheme.viewmodels.survey.js. The prepared setup on theme creation makes sure that all JavaScript files in the Themes/MyTheme/Scripts directory are included in the defult scripts bundle.
Creating a New Knockout.js View Model
You start by defining a simple knockout view model using a variant of the module pattern an placing it under the MyTheme.ViewModels namespace:
var MyTheme = MyTheme || {};
MyTheme.ViewModels = MyTheme.ViewModels || {}; // 1.
(function (exports, _, ko) { // 2.
'use strict';
function ProfessionalSurveyModel() {
var self = this; // 3.
self.wantsToFillOutSurvey = ko.observable(true); // 4.
self.optOutOfSurvey = function () { // 5.
self.wantsToFillOutSurvey(false);
};
self.optInToSurvey = function () { // 5.
self.wantsToFillOutSurvey(true);
};
}
_.extend(exports, { // 6.
ProfessionalSurveyModel: ProfessionalSurveyModel
});
})(MyTheme.ViewModels, _, ko); // 7.
- Define the
MyThemeandMyTheme.ViewModelsnamespaces, if they don’t already exist. - The namespace the module
exportsto and underscore.js and knockout.js dependencies. - Use the common knockout.js pattern of assigning
thistoselfso you don’t need to bindthisto our functions. (see Managing ‘this’ in knockout’s Computed Observables documentation) - Use the knockout observable to keep track if the customer wants to fill out the survey or not. Set it to
trueto begin with, so the customer has to opt out of filling it in. - Bind functions to clicking a link to let the user opt in or opt out of taking part in the survey.
- Extend the
MyTheme.ViewModelsnamespace with yourProfessionalSurveyModelconstructor. The actual namespace and dependencies that are used in 2. - Make an instance of your
ProfessionalSurveyModelview model inAccount/_ExtraScripts.cshtml:<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%3E%0A%20%20%20%20Atomia.VM.survey%20%3D%20new%20MyTheme.ViewModels.ProfessionalSurveyModel()%3B%0A%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="<script>" title="<script>" />
- The
ProfessionalSurveyModelis instantiated as a sub-model of theAtomia.VMview model.Atomia.VMand all sub-models will be activated on the page via a call toko.applyBindings(Atomia.VM). - Now you just need to set up the bindings between your
Atomia.VM.surveymodel and the markup you added toAccount/_ExtraForms.cshtmlabove: -
@model MyTheme.Models.MyAccountViewModel <div class="pro-survey" data-bind="with: survey"> // 1. <h4> @Html.CommonResource("ProfessionalSurvey") <span data-bind="visible: !wantsToFillOutSurvey()" style="display:none;"> // 2. (<a href="javascript:void(0);" data-bind="click: optInToSurvey">Sure, I'll take the survey</a>) </span> <span data-bind="visible: wantsToFillOutSurvey"> // 3. (<a href="javascript:void(0);" data-bind="click: optOutOfSurvey">No thanks!</a>) </span> </h4> <div data-bind="slideVisible: wantsToFillOutSurvey"> // 4. @Html.FormRowFor(m => m.Survey.JobTitle, Html.CommonResource("JobTitle") + ":", true, "if: wantsToFillOutSurvey") // 5. @Html.FormRowFor(m => m.Survey.Department, Html.CommonResource("Department") + ":", false, "if: wantsToFillOutSurvey") // 5. </div> </div> - Set the scope of the contained data bindings to
survey(short forAtomia.VM.survey.) - Hide the opt-in link to start, and set up the
clickbinding to opt in to the survey. - Show the the opt-out link, and set up the
clickbinding to opt out of the survey. - Use the custom
slideVisiblebinding, which is the same as the standardvisible, except it slides down for a more pleasant experience. - For both form rows, set up an
ifbinding to keep theinputbindings in the DOM only if the user wants to fill out the survey. This has the effect of these fields not being posted on form submit if the user has opted out, and subsequently the ASP.NET MVC model binder will not try to bind these fields and skip validation ofJobTitleeven though it is annotated as required.
Extending an Existing Knockout.js View Model
You now have a functioning survey. However, you might want to handle it differently depending on if the customer is an individual or a company. So let’s add a requirement that if the customer is an individual the survey is opt-in and if the customer is a company the survey is opt-out.
- Access the
mainContactCustomerTypeon theAtomia.VM.accountmodel, which is created via theAtomia.ViewModels.AccountModelconstructor. You want the start value of thewantsToFillOutSureyto depend on themainContactCustomerType. You also want to open the survey if the customer changes customer type to"company". - Modify the initialization of the survey to change
Atomia.VM.accountinstead of creating a separate model. Here you use theAtomia.Utils.mixmethod to combine the two constructors’AccountModelandProfessionalSurveyModelto create a single view model object. When you mix like this the properties declared byAccountModelare available to theProfessionalSurveyModelconstructor. (Please note that the constructor itself is the argument, not an object created by the constructor, and that only constructors without arguments are supported.)
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%3E%0A%20%20%20%20Atomia.VM.account%20%3D%20Atomia.Utils.mix(%0A%20%20%20%20%20%20%20%20Atomia.ViewModels.AccountModel%2C%0A%20%20%20%20%20%20%20%20MyTheme.ViewModels.ProfessionalSurveyModel)%3B%0A%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="<script>" title="<script>" />
- Modify the
ProfessionalSurveyModelconstructor to work with some of theAccountModelproperties:
var MyTheme = MyTheme || {};
MyTheme.ViewModels = MyTheme.ViewModels || {};
(function (exports, _, ko) {
'use strict';
function ProfessionalSurveyModel() {
var self = this;
self.wantsToFillOutSurvey = ko.observable(self.mainContactIsCompany()); // 1.
self.optOutOfSurvey = function () {
self.wantsToFillOutSurvey(false);
};
self.optInToSurvey = function () {
self.wantsToFillOutSurvey(true);
};
self.mainContactCustomerType.subscribe(function(newCustomerType){ // 2.
if (newCustomerType === 'company') {
self.wantsToFillOutSurvey(true);
}
});
}
_.extend(exports, {
ProfessionalSurveyModel: ProfessionalSurveyModel
});
})(MyTheme.ViewModels, _, ko);
- Set up the start value of the survey to
trueif the customer is"company". - Subscribe to changes on the
mainContactCustomerTypeproperty to setwantsToFillOutSurveytotrueif the customer is a"company". - The markup defined for the survey should work almost without change. Since setup a binding context with the
withbinding you only need to change that so that the markup binds to theAtomia.VM.accountmodel that you are extending, and not the now non-existingAtomia.VM.surveymodel:
<div class="pro-survey" data-bind="with: account">
...
</div>
In the example above you only used some of the properties from the AccountModel, but by the nature of JavaScript you might just as easily have redefined some properties as well.