Registration form with multiple pages?

Topics: Customizing Orchard, Writing modules, Writing themes
Dec 29, 2011 at 10:52 PM

How do i create a form with multiple pages? The user account should be created on p1 submit, and p2 should collect more details. It would be easy to do if I split up the fields into parts that each had their own db tables, but I want one "userProfile" table with name, address, and other fields, and for some of the fields to be saved on p1, and the others to be saved to the same table on p2. 

 

The next best method I think would be to have separate tables for everything, and each field to be a Content Part. 

 

I am having trouble figuring out how to customize this because so far all examples assume you want to follow convention of Parts or types displayed/edited/saved all at once. I am playing with Profile and Extended Registration modules and I have my custom fields displaying on p1 so far. 

Dec 30, 2011 at 4:13 AM

A little more detail: 

I'm editing the AccountController in itWORKS.ExtendedRegistration to get a proof of concept for the multi-page registration. I want page 1 to have username/pass, first/last name, and a couple other fields. When p1 is submitted I want the Account to be created and for the user to see p2 which has the AddressPart fields. 

Right now the problem is even if I remove the AddressPart from displaying on p1, it looks like the Model validation logic in the AccountController's [HttpPost] Register()  method still validates the Address part fields (where I have set "Required" attributes on City, and Line 1). 

How can run the validation for the UserPart on p1 submit, but skip the validation for some or all of the child parts (AddressPart)? 

Dec 30, 2011 at 1:09 PM

It might be best to leverage the "groupId" property of content rendering to achieve this. You can see how this is used already for rendering the Site configuration on multiple pages in admin.

Dec 30, 2011 at 10:23 PM

I'm looking at it, but so far it doesn't look like groupId is what I need. GroupId on the Dashboard serves to allow modules to add sections to the Dashboard navigation. At least that's what it seems like, but I could be wrong. Is there some documentation that would help point me in the right direction? I haven't found any for groupId related stuff in the official Orchard Docs. 

I'm looking into doing this by modifying my controller with an "int _pageNumber { get; set; }" property that will be set by the Controller action methods. Then, since the controller is also implementing IUpdateModel, it can try the validation based on the value of _pageNumber. 

Not sure if I'll also be able to intercept the saving to the repository using a similar method either, but throwing it out there in case someone can provide more guidance. 

Dec 30, 2011 at 11:11 PM
Edited Dec 30, 2011 at 11:12 PM

Another idea I have, but am not sure how to implement, is to programatically remove the AddressPart property from my UserProfile shape. I hope this would prevent AddressPart's model editor from displaying on p1, as well as from validating on p1 submit. 

i see userPart.Parts is a collection where the fouth item is the AddressPart that I want to remove. How do I do that? userPart.Parts is an IEnumerable<ContentPart>. I don't see a method that allows one to remove elements from that collection. 

       
[HttpPost]
        public ActionResult Register(string email, string password, string confirmPassword)
        {
            _pageNumber = 1; 
            string userName = email; 
            // ensure users can register
            var registrationSettings = _orchardServices.WorkContext.CurrentSite.As<RegistrationSettingsPart>();
            if (!registrationSettings.UsersCanRegister)
            {
                return HttpNotFound();
            }
            ViewData["PasswordLength"] = MinPasswordLength;

            var shape = _orchardServices.New.Register();

            // validate user part, create a temp user part to validate input
            var userPart = _orchardServices.ContentManager.New("User");
            // I want to insert code here that removes the AddressPart item from userPart.Parts 
            if (userPart != null)
            {
                shape.UserProfile = _orchardServices.ContentManager.UpdateEditor(userPart, this);
                if (!ModelState.IsValid)
                {
                    _orchardServices.TransactionManager.Cancel();
                    return new ShapeResult(this, shape);
                }
            }
Dec 31, 2011 at 4:02 AM

There isn't any documentation for groupId, but what is happening is the Site content item is being displayed, but only certain parts are shown, and this is decided by groupId. Each page of settings uses a different groupId. So it's a multi-page editor for a single content item. The problem is how to actually *set* the groupId for each part/shape, this is usually done from the driver, although I've been working on some extensions to Placement that will allow you to set groupId in Placement.info.

Jan 3, 2012 at 1:49 AM
Edited Jan 3, 2012 at 6:05 AM

UPDATE: Editing some of my previous reply here -- some stuff I thought was not working is actually working once I did a clean build, and stopped and restarted VS2010's asp.net development web server.

The problem appears to be solved now. @randompete, thanks a lot for your help. 

I'm setting groupId in the handler like this:

 

protected override void GetItemMetadata(GetContentItemMetadataContext context)
        {
            if (context.ContentItem.ContentType != "User")
                return;
            base.GetItemMetadata(context);
            context.Metadata.EditorGroupInfo.Add(new GroupInfo(T("page2")));
        }

Controller:

Here I set up the rendering context to use the "page1" groupId in the "Register" action, and "page2" groupId in the "CompleteYourProfile" action. 

        public ActionResult Register()
        {
            // ensure users can register
            var registrationSettings = _orchardServices.WorkContext.CurrentSite.As<RegistrationSettingsPart>();
            if (!registrationSettings.UsersCanRegister)
            {
                return HttpNotFound();
            }

            ViewData["PasswordLength"] = MinPasswordLength;
            var shape = _orchardServices.New.Register();

            var user = _orchardServices.ContentManager.New("User");
            if (user != null)
            {
                shape.UserProfile = _contentManager.BuildEditor(user,"page1");
            }

            return new ShapeResult(this, shape);
        }



        [HttpPost]
        public ActionResult Register(string email, string password, string confirmPassword)
        {
            string userName = email; 
            // ensure users can register
            var registrationSettings = _orchardServices.WorkContext.CurrentSite.As<RegistrationSettingsPart>();
            if (!registrationSettings.UsersCanRegister)
            {
                return HttpNotFound();
            }
            ViewData["PasswordLength"] = MinPasswordLength;

            var shape = _orchardServices.New.Register();

            // validate user part, create a temp user part to validate input
            var userPart = _orchardServices.ContentManager.New("User");
            
            if (userPart != null)
            {
                shape.UserProfile = _orchardServices.ContentManager.UpdateEditor(userPart, this, "page1");
                if (!ModelState.IsValid)
                {
                    _orchardServices.TransactionManager.Cancel();
                    return new ShapeResult(this, shape);
                }
            }

            if (ValidateRegistration(userName, email, password, confirmPassword))
            {
                // Attempt to register the user
                // No need to report this to IUserEventHandler because _membershipService does that for us
                var user = _membershipService.CreateUser(new CreateUserParams(userName, password, email, null, null, false));

                if (user != null)
                {
                    // we know userpart data is ok, now we update the 'real' recently published userpart
                    _orchardServices.ContentManager.UpdateEditor(user.ContentItem, this);

                    var userPart2 = user.As<UserPart>();
                    if (user.As<UserPart>().EmailStatus == UserStatus.Pending)
                    {
                        _userService.SendChallengeEmail(user.As<UserPart>(), nonce => Url.AbsoluteAction(() => Url.Action("ChallengeEmail", "Account", new { Area = "Orchard.Users", nonce = nonce })));

                        foreach (var userEventHandler in _userEventHandlers)
                        {
                            userEventHandler.SentChallengeEmail(user);
                        }
                        return RedirectToAction("ChallengeEmailSent", "Account", new { area = "Orchard.Users" });
                    }


                    ///TODO: Comment out this part, since we want to make email verification optional
                    ///TODO: Should probably also create a new set of statuses to fit with the "optional email verification" model we're using
                    if (user.As<UserPart>().RegistrationStatus == UserStatus.Pending)
                    {
                        return RedirectToAction("RegistrationPending", "Account", new { area = "Orchard.Users" });
                    }

                    _authenticationService.SignIn(user, false /* createPersistentCookie */);
                    
                    return Redirect(Url.RouteUrl("Register-CompleteProfile"));
                }

                ModelState.AddModelError("_FORM", T(ErrorCodeToString(/*createStatus*/MembershipCreateStatus.ProviderError)));
            }

            // If we got this far, something failed, redisplay form
            //var shape = _orchardServices.New.Register();
            return new ShapeResult(this, shape);


            return null;
        }


        public ActionResult CompleteYourProfile()
        {
            if (_orchardServices.WorkContext.CurrentUser == null)
            {
                return HttpNotFound();
            }

            IUser user = _orchardServices.WorkContext.CurrentUser;
            dynamic userShape = _orchardServices.ContentManager.BuildEditor(user, "page2");
            var completeYourProfileShape = _orchardServices.New.CompleteYourProfile(UserProfile : userShape);
            
            return new ShapeResult(this, completeYourProfileShape);
        }


        [HttpPost, ActionName("CompleteYourProfile")]
        public ActionResult CompleteYourProfilePost()
        {
            if (_orchardServices.WorkContext.CurrentUser == null)
            {
                return HttpNotFound();
            }

            IUser user = _orchardServices.WorkContext.CurrentUser;
            dynamic userShape = _orchardServices.ContentManager.UpdateEditor(user, this, "page2");
            var completeYourProfileShape = _orchardServices.New.CompleteYourProfile(UserProfile: userShape);
            
            if (!ModelState.IsValid)
            {
                _orchardServices.TransactionManager.Cancel();
                return new ShapeResult(this, completeYourProfileShape);
            }

            return new ShapeResult(this, completeYourProfileShape);
        }

AddressDriver.cs:

    public class AddressDriver : ContentPartDriver<AddressPart>
    {
        private readonly IWorkContextAccessor _wca;


        protected override string Prefix { get { return "UserProfile.Address"; } }


        public AddressDriver(IWorkContextAccessor wca) 
        {
            _wca = wca;
        }


        protected override DriverResult Display(AddressPart addressPart, string displayType, dynamic shapeHelper)
        {
            return ContentShape(
                "Parts_Address"
                , () => shapeHelper.Parts_Address(
                    Street1: addressPart.Street1
                    , Street2: addressPart.Street2
                    , City: addressPart.City
                    , State: addressPart.State
                    , Zip: addressPart.Zip
                    , Country: addressPart.Country
                )
            ); 
        }


        // HTTP GET:
        protected override DriverResult Editor(AddressPart part, dynamic shapeHelper)
        {
            return ContentShape(
                "Parts_Address_Edit"
                , () => shapeHelper.EditorTemplate(
                    TemplateName: "Parts/Address"
                    , Model: BuildAddressViewModel(part)
                    , Prefix: Prefix
                )
            ).OnGroup("page2");
        }


        // HTTP POST:
        protected override DriverResult Editor(AddressPart part, IUpdateModel updater, dynamic shapeHelper)
        {
            var model = new EditAddressViewModel();
            updater.TryUpdateModel(model, Prefix, null, null);

            if (part.ContentItem.Id != 0)
            {
                part.Street1 = model.Street1;
                part.Street2 = model.Street2;
                part.City = model.City;
                part.State = model.State;
                part.Zip = model.Zip;
                part.Country = model.Country; 
            }

            return Editor(part, shapeHelper); 
        }


        private EditAddressViewModel BuildAddressViewModel(AddressPart part)
        {
            EditAddressViewModel avm = new EditAddressViewModel
            {
                Street1 = part.Street1,
                Street2 = part.Street2,
                City = part.City,
                State = part.State,
                Zip = part.Zip,
                Country = part.Country
            };
            return avm;
        }
    }

In my Views folder I have Register.cshtml and CompleteYourProfile.cshtml, which display the user profile like this: 

@Html.ValidationSummary()
@using (Html.BeginFormAntiForgeryPost())
{
    if (Model.UserProfile != null)
    {
        <fieldset>
            <legend></legend>
            @Display(Model.UserProfile)
        </fieldset>
    }
    <div>
        <button class="primaryAction" type="submit">@T("Submit")</button>
    </div>
}

 

This seems to work with my testing so far. The Address stuff is only rendered on the 2nd page (CompleteYourProfile action), and the validation for the address stuff only triggers after 2nd page is submitted. Sorry if some of my questions seemed n00bish, but I'm also picking up MVC at the same time I'm picking up Orchard. 

Jan 3, 2012 at 11:54 AM

Great, that's exactly how I meant; will probably be useful to others as well!

Jan 3, 2012 at 1:55 PM
Edited Jan 3, 2012 at 1:58 PM

I've also build a multi page registration but implemented it otherwise. Because i don't like to use editor displays in my front-end i used display types to make sure i return every step and other 'page'. So i have only 1 content item for all the steps but use different driver display results based on given display type which are defined in the 3 different controller actions.

Jan 3, 2012 at 2:37 PM
Edited Jan 3, 2012 at 2:39 PM

Znowman, can you post some code sample(s) of how you did that? I have seen the "display types" referenced in the code but I wasn't sure what it was. Is that an MVC concept or is it specific to Orchard? 

 

Also, why don't you like using Editor displays in the front end? 

Jan 4, 2012 at 7:41 AM

Because i don't have access to the code at the moment i'll try to explain (take your driver as example:

if (displayType == "Step1")
            {
                return ContentShape("Parts_Step1", () => shapeHelper.Parts_Address(
                    Street1: addressPart.Street1
                    , Street2: addressPart.Street2
                    , City: addressPart.City
                    , State: addressPart.State
                    , Zip: addressPart.Zip
                    , Country: addressPart.Country
                ));
            }
            else if (displayType == "Step2")
            {
                return ContentShape("Parts_Step1", () => shapeHelper.Parts_Address(
                    Street1: addressPart.Street1
                    , Street2: addressPart.Street2
                    , City: addressPart.City
                    , State: addressPart.State
                    , Zip: addressPart.Zip
                    , Country: addressPart.Country
                ));
            }

Now simply in your controller action build a display by content item and give the Step name as displaytype parameter

Jan 4, 2012 at 12:49 PM

It seems to me that groupId is a cleaner and more future-proof way to do this ...

Znowman, why don't you like using editor displays in the front end? There's nothing principally wrong with it so long as you check appropriate permissions ... And you'll have to reinvent many wheels by using display templates.

Jan 4, 2012 at 2:12 PM

Interesting. I hadn't thought of doing it that way, but I'll keep it in mind in case that can solve another problem in the future. 

Znowman, I'm still interested in hearing why you don't like editor displays. 

Does anyone know more conventional examples of when you'd want to vary the behavior of the Driver's Display() method by the displayType parameter? I'm specifically interested in whether or not there are places where Orchard passes in values for displayType, and if so, where it gets those values from. 

Jan 4, 2012 at 3:22 PM

Because i think editortemplates are ment to be used in the backend and not in the front-end. Also i think you can get security problems if some fields are not allowed and are not shown but still work when you manually post them. When you want to show the editor in backend but also in frontend you'll end up with the same result which is not always a good thing.