Creating an Event Registration form for Event Calendar

26 Aug

Creating an Event Registration form for Event Calendar

Overview

One question that I find myself answering frequently for users of the EventCalendar module is how to customize the module to handle registrations. A lot of people are inclined to look to the UserForms module, or SilverStripe’s homegrown “Events” module, or even thirdparty solutions to service this need. It’s understandable that we get so many different ideas about how to go about solving this problem. After all, event registration is something that can take on many forms, and doesn’t always follow a pattern suitable for cookie-cutter, turnkey solutions.

But EventCalendar is, itself, guilty of a lot of whiz-bang wizardry that often gets in your way as a developer, so in this tutorial, it’s fitting that we demonstrate something basic that will serve the needs of a website managing a light amount of registrations. The goals of this exercise will include:

  • Creating a set of subclasses for the EventCalendar module
  • Adding a “register” link to each event
  • Handling a registration through a public-facing form, and notifying an administrator
  • Saving the registration to the database
  • Tracking and enforcing available seats

Step 1: Break the Chains

When customising the EventCalendar module, it is almost always necessary to subclass its components. The advantage of this approach is that we don’t have to concede working with its existing structure. Further, subclasses allow us to insulate our changes, leaving the Event Calendar module in a position where upgrades are possible without breaking anything or reverting our customisations.

In this example, we have a website who is managing registrations for conferences. Depending on your use case, you’ll probably want to substitute the word “Conference” with something else when naming all of these classes.

In your code directory, create the following classes:

ConferenceHolder.php

class ConferenceHolder extends Calendar {
	static $has_many = array (
		'Conferences' => 'Conference'
	);

	static $allowed_children = array('Conference');

	static $hide_ancestor = 'Calendar';
}

class ConferenceHolder_Controller extends Calendar_Controller {
}

Nothing too special here. We’ve simply subclassed the largest component of EventCalendar (Calendar) and created our own version. We use $allowed_children to ensure nothing else sneaks into the hierarchy, and another useful SiteTree property $hide_ancestor, which is useful when subclassing modules. This will hide the parent class “Calendar” from the create dropdown in the CMS. It exists only as a parent class to our customised version.

The $has_many relationship to Conferences may seem a bit awkward, and that’s because, well, it is. Conferences is the child of this class, so no $has_many should really be necessary, but this is done to accommodate some poorly-informed coding that went into building EventCalendar, so let’s just consider it legacy support for now.

Conference.php

class Conference extends CalendarEvent {
	static $db = array (
		'Cost' => 'Currency'
	);

	static $has_many = array (
		'DateTimes' => 'ConferenceDateTime'
	);

	static $has_one = array (
		'ConferenceHolder' => 'ConferenceHolder'
	);

	static $can_be_root = false;

	static $hide_ancestor = 'CalendarEvent';

	public function getCMSFields() {
		$f = parent::getCMSFields();
		$f->addFieldToTab("Root.Content.Main", new CurrencyField('Cost',_t('Conference.COST','Conference Cost')),'Content');
		return $f;
	}

}

class Conference_Controller extends CalendarEvent_Controller {
}

We’ve done a little bit more customisation in this subclass, adding the cost of the conference. We set up the custom DateTime class in the $has_many relation, and we tie back the seemingly unnecessary $has_many from ConferenceHolder with a reciprocating $has_one. The ancestor “CalendarEvent” is hidden, and for good measure, we ensure this page type cannot be created without a parent.

ConferenceDateTime.php

class ConferenceDateTime extends CalendarDateTime {
	static $db = array (
		'TicketsAvailable' => 'Int'
	);

	static $has_one = array (
		'Conference' => 'Conference'
	);

	public function extendTable() {
		$this->addTableTitles(array(
			'TicketsAvailable' => _t('Conference.TICKETSAVAILABLE','Tickets available')
		));

		$this->addPopupFields(array(
			new NumericField('TicketsAvailable', _t('Conference.TICKETSAVAILABLE','Tickets available'))
		));
	}

	public function CanRegister() {
		return $this->TicketsAvailable > 0;
	}

	public function RegisterLink() {
		return $this->Conference()->Parent()->Link("register")."?DateID=$this->ID";
	}

}

Every event has multiple dates, so all of the date and time information is stored on a separate table in EventCalendar. In the above example, we’ve customised the CalendarDateTime class to fit our Conference model. Because tickets will be available for a particular date and time, the “TicketsAvailable” field goes in this class. An simple example of this model is a movie theater, showing a single movie at multiple dates and times. The movie is the CalendarEvent, the individual showings are the CalendarDateTimes, and the theater itself is the Calendar.

The extendTable() method is a bit curious, and is another relic of the days when I didn’t quite get SilverStripe. My thinking here was that the user shouldn’t have to concern himself with messing with the DataObjectManager to manage dates, so I created a simple API for updating it. It is what it is. Fortunately, it’s pretty self-explanatory.

In the RegisterLink() function, we pass link a controller action “register” and an ID. We’ll deal with that later. I’ve also added a CanRegister() function just in case the TicketsAvailable field gets messed up and goes below zero or something strange.

Now that the datamodel is in place, run a /dev/build.

If everything looks good, go into the CMS and create a ConferenceHolder with a few child Conference pages, and assign a few dates to each Conference.

The data model is complete!

Step Two: Setting Up the Templates

Looking back on the Conference class, we created a RegisterLink() function that will serve as a template accessor for the registration link for the given event. That means it’s time to update the templates.

Create:

  • /themes/your_theme/templates/Layout/ConferenceHolder.ss
  • /themes/your_theme/templates/Layout/Conference.ss

In those files, paste in the contents from:

  • /event_calendar/templates/Layout/Calendar.ss
  • /event_calendar/template/Layout/CalendarEvent.ss

Now run ?flush=1. SilverStripe will now use these custom templates to override those in the core. If you are like me, and don’t use themes, substitute /themes/your_theme/ with /mysite/, or whatever your project directory is.

Customise the templates structurally as you see fit. For these next changes, I’ll just post the relevant snippets:

ConferenceHolder.ss


Conference.ss

$_Dates

($Cost.Nice)
  • $_Times

Now we have registration links for all of our dates. If you’ve clicked on any, you’ll probably get an error. That’s because we need to update our controller to accept the register action. We’ll do that in the next step.

Step Three: Customising the Controller

With the new register action for ConferenceHolder, it is necessary to add the function to the $allowed_actions array. This array tells SilverStripe which member functions are allowed to be executed from the URL. For security purposes, most methods are not.

ConferenceHolder.php

class ConferenceHolder_Controller extends Calendar_Controller {

	static $allowed_actions = array (
		'register',
		'RegistrationForm'
	);
}

We’ll need the RegistrationForm() function later. Next, let’s create the register() function itself in that same controller.

	public function register(SS_HTTPRequest $request) {
		if(!$request->requestVar('DateID')) {
			return Director::redirectBack();
		}
		return array();
	}

This controller action, referenced in ConferenceDateTime::RegisterLink(), does a sanity check to make sure we were passed a DateID in the request, and then returns the template. In this case, the template that the request handler is going to look for is ConferenceHolder_register.ss. Whenever a controller action is used, SilverStripe will first look for _[function name] first, and fall back on the main template.

All controller actions must return an array of template variables and their values to the template. In this case, since we’re going to define RegistrationForm() as a public function in the controller anyway, it’s not necessary to return anything, so an empty array is all we need.

Now let’s build the form, in that same controller.

	public function RegistrationForm() {
		$date_id = (int) $this->getRequest()->requestVar('DateID');
		if(!$date = DataObject::get_by_id("ConferenceDateTime", $date_id)) {
			return $this->httpError(404);
		}
		$date_map = array();
		if($conference = $date->Conference()) {
			if($all_dates = $conference->DateTimes()) {
				$date_map = $all_dates->toDropdownMap('ID','DateLabel');
			}
		}
		return new Form (
			$this,
			"RegistrationForm",
			new Fieldset (
				new TextField('Name', _t('Conference.Name','Name')),
				new EmailField('Email', _t('Conference.EMAIl','Email')),
				new DropdownField('DateID', _t('Conference.CHOOSEDATE','Choose a date'), $date_map, $date_id)
			),
			new FieldSet (
				new FormAction('doRegister', _t('Conference.REGISTER','Register'))
			),
			new RequiredFields('Name','Email','DateID')
		);
	}

The first thing we have to do is check if a DateID was sent. Keep in mind, this can come one of two ways: 1) through the query string in the URL, if the user is coming from a registration link, and 2) as posted form data after the user has filled out the form. RegistrationForm() is actually run again when the form is posted. Therefore, it’s important that the DateID field in the form matches the DateID param that we’re using the in URL. It makes verification a lot easier when the request param only has one name.

If the DateID is passed, but doesn’t exist in the database, a 404 error is thrown. Realistically, that should never happen.

As a usability enhancement, I’ve made the DateID a dropdown in the form, containing all of the other dates related to the same conference. If your events only have one date, then it might make more sense to use a HiddenField for DateID. Notice in the toDropdownMap() function, we use a custom getter, DateLabel. We use this custom getter to render dynamic text for each option in the dates dropdown. Let’s look at the getDateLabel() function.

ConferenceDateTime.php

	public function getDateLabel() {
		return $this->obj('StartDate')->Format('d-m-Y').", " . $this->obj('StartTime')->Nice24() . " : (" . sprintf(_t('Conference.TICKETSREMAINING','%d tickets remaining'), $this->TicketsAvailable).")";
	}

The label is a concatenation of the start date, the time, and the number of tickets remaining, in parenthesis.

Now let’s set up a handler for the form. It would be nice if the registrations saved to the database, in a table on the ConferenceHolder page in the CMS. Let’s define a ConferenceRegistration dataobject.

ConferenceRegistration.php

class ConferenceRegistration extends DataObject {

	static $db = array (
		'Name' => 'Varchar',
		'Email' => 'Varchar'
	);

	static $has_one = array (
		'Date' => 'ConferenceDateTime',
		'ConferenceHolder' => 'ConferenceHolder'
	);

	static $summary_fields = array (
		'Name' => 'Name',
		'Email' => 'Email',
		'ConferenceLabel' => 'Conference',
		'DateLabel' => 'Date'
	);

	public function getConferenceLabel() {
		if($this->Date()) {
			return $this->Date()->Conference()->Title;
		}
	}

	public function getDateLabel() {
		if($this->Date()) {
			return $this->Date()->_Dates();
		}
	}
}

The $db array contains information about the user who has signed up, and of course, we have a relation to ConferenceDateTime to tell the administrator for which date the user registered. There are also a couple of custom getters here that are used in the $summary_fields array to create some nice labels for what will be a DataObjectManager on the ConferenceHolder page.

ConferenceHolder_Controller

	public function doRegister($data, $form) {
		// Sanity check
		if(!isset($data['DateID'])) {
			return Director::redirectBack();
		}
		if(!$date = DataObject::get_by_id("ConferenceDateTime", (int) $data['DateID'])) {
			return $this->httpError(404);
		}

		$conference = $date->Conference();

		// Save the registration
		$form->saveInto($reg = new ConferenceRegistration());
		$reg->ConferenceHolderID = $date->Conference()->ParentID;
		$reg->write();

		// Decrease the tickets available
		$date->TicketsAvailable--;
		$date->write();

		// Email the admin
		$email = new Email($data['Email'], "administrator@yoursite.com", "Event Registration: {$conference->Title}");
		$email->ss_template = "ConferenceRegistration";
		$email->populateTemplate(array(
			'Registration' => $reg
		));
		$email->send();

		$form->sessionMessage(_t('Conference.THANKYOU','Thank you for signing up!'),'good');
		return Director::redirectBack();
	}

After validating the date ID, ensuring it is present and in the database, we see the benefit of creating a ConferenceRegistration object. The form saves into it easily. That’s because all of the form fields (Name, Email, and DateID) all correspond with fields on the ConferenceRegistration object. If we had named the field “FirstName” instead of “Name” on the form, it wouldn’t work, and we would have to set the field manually.

Next, we decrement the number of tickets available, having confirmed the registration.

Finally, let’s send an email to the administrator. In this case, all we need to pass to the template is the ConferenceRegistration object, since it contains data about both the user and the date registered.

Let’s build a template for that email.

/themes/your_theme/templates/email/ConferenceRegistration.ss

Dear Administrator,

$Name has registered for the event $Conference.Title on $Date._Dates

Make sure you run a ?flush=1 to load this new template into the manifest.

Now that all the code is in place for the form, we can add it to its new template:

/themes/your_theme/templates/Layout/ConferenceHolder_register.ss

		$RegistrationForm

The last thing we need to do is add the new ConferenceRegistration table to the ConferenceHolder.

ConferenceHolder.php

class ConferenceHolder extends Calendar {
	static $has_many = array (
		'Conferences' => 'Conference',
		'Registrations' => 'ConferenceRegistration'
	);

	static $allowed_children = array('Conference');

	static $hide_ancestor = 'Calendar';	

	public function getCMSFields() {
		$f = parent::getCMSFields();
		$f->addFieldToTab("Root.Content.Registrations", new DataObjectManager(
			$this,
			'Registrations',
			'ConferenceRegistration'
		));
		return $f;
	}
}

At this point, you should be ready to test your registration form! That concludes this tutorial. If you have any questions, or if you find an error in the example code, please post a comment using the form below.