Now that the design is complete, you should have a very clear idea about what is required, so now we can consider how we're going to implement this functionality. You'll follow the same order as the "Design" section, starting with the creation of database tables and stored procedures, the configuration, DAL and BLL classes, and finally the ASPX pages and the PollBox user control.
The tables and stored procedures required for this module are added to the same sitewide SQL Server 2005 database (aspnetdb.mdf) shared by all modules, although the configuration settings enable you to have the data and the db objects separated into multiple databases if you prefer to do it that way. It's easy to create the required objects with Visual Studio 2005 using the integrated Server Explorer, which has been enhanced in the 2005 version so that it's almost like using SQL Server 2000's Enterprise Manager, but right from within the Visual Studio IDE. Figure 6-4 is a screenshot of the IDE when adding columns to the tbh_Polls tables, and setting the properties for the PollID primary key column.
After creating the two tables with the columns indicated in Figure 6-1, you need to create a relationship between them over the PollID column, and set up cascade updates and deletes (Select Data ð Add New ð Diagram to bring up the interactive diagram that enables you to create the relationship — as explained in Chapter 5). Figure 6-5 is a screenshot of the relationship diagram.
Now we'll cover the most interesting stored procedures (as mentioned earlier, the code for all stored procedures is in the code download for this book).
This procedure inserts a new poll, in the form of a new row into the tbh_Polls table. It takes input values for all fields of this table, minus the IsArchived and ArchivedDate columns because archiving is not possible when creating a new poll. When adding a new poll you can pass true in IsCurrent if you want this new poll to be the default poll, in which case the procedure first resets the IsCurrent field of all existing rows to 0:
ALTER PROCEDURE dbo.tbh_Polls_InsertPoll ( @AddedDate datetime, @AddedBy nvarchar(256), @QuestionText nvarchar(256), @IsCurrent bit, @PollID int OUTPUT ) AS SET NOCOUNT ON BEGIN TRANSACTION InsertPoll IF @IsCurrent = 1 BEGIN UPDATE tbh_Polls SET IsCurrent = 0 END INSERT INTO tbh_Polls (AddedDate, AddedBy, QuestionText, IsCurrent, IsArchived, ArchivedDate) VALUES (@AddedDate, @AddedBy, @QuestionText, @IsCurrent, 0, null) SET @PollID = scope_identity() IF @@ERROR > 0 BEGIN RAISERROR('Insert of poll failed', 16, 1) ROLLBACK TRANSACTION InsertPoll RETURN 99 END COMMIT TRANSACTION InsertPoll
Because the stored procedure may run two separate statements (if the IsCurrent parameter is true), the code is wrapped into a TSQL transaction so that if the second statement fails for some reason, then the first statement running the UPDATE is rolled back as well. We're also using a transaction in the tbh_Polls_UpdatePoll procedure.
This procure is used to archive a poll. It only takes the ID of the poll to archive. The poll's ArchivedDate field is set to the current date/time, retrieved by the TSQL's getdate function:
ALTER PROCEDURE dbo.tbh_Polls_ArchivePoll ( @PollID int ) AS UPDATE tbh_Polls SET IsCurrent = 0, IsArchived = 1, ArchivedDate = getdate() WHERE PollID = @PollID
The tbh_Polls_GetPolls procedure takes two parameters that specify whether the active and archived polls are included in the resultset to be returned, respectively. There are four possible combinations (active only, archived only, both archived and active, none), determined by the value of a single IsArchived field (of course, it makes no sense to call this stored procedure if you don't want either active or archived polls). Instead of writing three separate SELECTs, and choosing which one to execute according the input values, we'll run a single statement with two WHERE conditions to determine which rows to select (active or archived). The two conditions are defined once, in the single SELECT statement, but their values are stored in private variables according to the values of the procedure's input. This procedure also adds a calculated column named Votes to the resultset, whose value is the sum of the Votes field of all child response options. Following is the complete code:
ALTER PROCEDURE dbo.tbh_Polls_GetPolls ( @IncludeActive bit, @IncludeArchived bit ) AS SET NOCOUNT ON DECLARE @IsArchived1 bit DECLARE @IsArchived2 bit SELECT @IsArchived1 = 1 SELECT @IsArchived2 = 0 IF @IncludeActive = 1 AND @IncludeArchived = 0 BEGIN SELECT @IsArchived1 = 0 SELECT @IsArchived2 = 0 END IF @IncludeActive = 0 AND @IncludeArchived = 1 BEGIN SELECT @IsArchived1 = 1 SELECT @IsArchived2 = 1 END SELECT PollID, AddedDate, AddedBy, QuestionText, IsCurrent, IsArchived, ArchivedDate, (SELECT SUM(Votes) FROM tbh_PollOptions WHERE PollID = P.PollID) AS Votes FROM tbh_Polls P WHERE IsArchived = @IsArchived1 OR IsArchived = @IsArchived2 ORDER BY IsArchived ASC, ArchivedDate DESC, AddedDate DESC
This procedure returns all data, including the calculated Votes field, for a single poll, whose ID is passed as an input:
ALTER PROCEDURE dbo.tbh_Polls_GetPollByID ( @PollID int ) AS SET NOCOUNT ON SELECT PollID, AddedDate, AddedBy, QuestionText, IsCurrent, IsArchived, ArchivedDate, (SELECT SUM(Votes) FROM tbh_PollOptions WHERE PollID = @PollID) AS Votes FROM tbh_Polls WHERE PollID = @PollID
This procedure returns the ID of the poll row with the IsCurrent field set to true. If there is no such row, the SELECT would return NULL. In that case, -1 is returned instead, so that the client code will be able to safely cast the result to an integer and then give a special meaning to that value:
This procedure returns the options for the specified poll, including all their values and a new calculated column, Percentage, which indicates the percentage of votes received by each poll out of the total number of votes received by all poll response options. Here's the code:
ALTER PROCEDURE dbo.tbh_Polls_GetOptions ( @PollID int ) AS SET NOCOUNT ON DECLARE @TotVotes int SELECT @TotVotes = SUM(Votes) FROM tbh_PollOptions WHERE PollID = @PollID IF @TotVotes = 0 BEGIN SELECT @TotVotes = 1 END SELECT OptionID, AddedDate, AddedBy, PollID, OptionText, Votes, (CAST(Votes as decimal) * 100 / @TotVotes) As Percentage FROM tbh_PollOptions WHERE PollID = @PollID ORDER BY AddedDate ASC
It first gets the total number of votes for all the options of this poll, and then saves this value in a local variable. If no votes are found, it sets the variable to 1, because otherwise the forthcoming division would produce an error. Finally, it executes the SELECT query and adds the Percentage column, with the following code:
(CAST(Votes as decimal) * 100 / @TotVotes) As Percentage
The number of votes for a response option is multiplied by 100 and then divided by the total number of votes for the poll. It's worth noting that the expression is CAST to Decimal, in order to achieve more accuracy than an integer percentage would provide.
The code for this procedure is similar to the code of the sp_Polls_GetOptions procedure, with two exceptions. First, it is restricted to a single record. Second, the parent poll's ID is not provided as a parameter but must be retrieved with a simple SELECT query that selects the PollID field of the specified response option:
ALTER PROCEDURE dbo.tbh_Polls_GetOptionByID ( @OptionID int ) AS SET NOCOUNT ON DECLARE @PollID int SELECT @PollID = PollID FROM tbh_PollOptions WHERE OptionID = @OptionID DECLARE @TotVotes int SELECT @TotVotes = SUM(Votes) FROM tbh_PollOptions WHERE PollID = @PollID IF @TotVotes = 0 BEGIN SELECT @TotVotes = 1 END SELECT OptionID, AddedDate, AddedBy, PollID, OptionText, Votes, (CAST(Votes As decimal) * 100 / @TotVotes) As Percentage FROM tbh_PollOptions WHERE OptionID = @OptionID
The custom configuration class must be developed before any other code because the custom settings are used in all other layers. This class is similar to the one seen in the previous chapter. It inherits from ConfigurationElement and has the properties previously defined:
public class PollsElement : ConfigurationElement { [ConfigurationProperty("connectionStringName")] public string ConnectionStringName { get { return (string)base["connectionStringName"]; } set { base["connectionStringName"] = value; } } public string ConnectionString { get { string connStringName = (string.IsNullOrEmpty(this.ConnectionStringName) ? Globals.Settings.DefaultConnectionStringName : this.ConnectionStringName); return WebConfigurationManager.ConnectionStrings[ connStringName].ConnectionString; } } [ConfigurationProperty("providerType", DefaultValue = "MB.TheBeerHouse.DAL.SqlClient.SqlPollsProvider")] public string ProviderType { get { return (string)base["providerType"]; } set { base["providerType"] = value; } } [ConfigurationProperty("votingLockInterval", DefaultValue = "15")] public int VotingLockInterval { get { return (int)base["votingLockInterval"]; } set { base["votingLockInterval"] = value; } } [ConfigurationProperty("votingLockByCookie", DefaultValue = "true")] public bool VotingLockByCookie { get { return (bool)base["votingLockByCookie"]; } set { base["votingLockByCookie"] = value; } } [ConfigurationProperty("votingLockByIP", DefaultValue = "true")] public bool VotingLockByIP { get { return (bool)base["votingLockByIP"]; } set { base["votingLockByIP"] = value; } } [ConfigurationProperty("archiveIsPublic", DefaultValue = "false")] public bool ArchiveIsPublic { get { return (bool)base["archiveIsPublic"]; } set { base["archiveIsPublic"] = value; } } [ConfigurationProperty("enableCaching", DefaultValue = "true")] public bool EnableCaching { get { return (bool)base["enableCaching"]; } set { base["enableCaching"] = value; } } [ConfigurationProperty("cacheDuration")] public int CacheDuration { get { int duration = (int)base["cacheDuration"]; return (duration > 0 ? duration : Globals.Settings.DefaultCacheDuration); } set { base["cacheDuration"] = value; } } }
To make this class map a <polls> element under the top-level <theBeerHouse> section, we add a property of type PollsElement to the TheBeerHouseSection class developed in the previous chapter and then use the ConfigurationProperty attribute to do the mapping:
public class TheBeerHouseSection : ConfigurationSection { // other properties here... [ConfigurationProperty("polls", IsRequired = true)] public PollsElement Polls { get { return (PollsElement)base["polls"]; } } }
If you want to make the archive available to everyone, and disable vote locking by the user's IP, you would use these settings in the web.config file:
<theBeerHouse defaultConnectionStringName="LocalSqlServer"> <contactForm mailTo="mbellinaso@wrox.com"/> <articles pageSize="10" /> <polls archiveIsPublic="true" votingLockByIP="false" /> </theBeerHouse>
The default value will be used for all those settings not explicitly defined in the configuration file, such as connectionStringName, providerType, votingLockByCookie, votingLockInterval, and the others.
The PollsProvider abstract class starts with the Instance static property that uses reflection to create, and return, an instance of the concrete provider class specified in the web.config section for data access methods. The constructor reads some other configuration settings (the connection string, whether the caching is enabled and for how long) and saves them in the DataAccess' common properties. We also have a list of data access methods, having only a signature without an implementation:
public abstract class PollsProvider : DataAccess { static private PollsProvider _instance = null; /// <summary> /// Returns an instance of the provider type specified in the config file /// </summary> static public PollsProvider Instance { get { if (_instance == null) _instance = (PollsProvider)Activator.CreateInstance( Type.GetType(Globals.Settings.Polls.ProviderType)); return _instance; } } public PollsProvider() { this.ConnectionString = Globals.Settings.Polls.ConnectionString; this.EnableCaching = Globals.Settings.Polls.EnableCaching; this.CacheDuration = Globals.Settings.Polls.CacheDuration; } // methods that work with polls public abstract List<PollDetails> GetPolls(bool includeActive, bool includeArchived); public abstract PollDetails GetPollByID(int pollID); public abstract int GetCurrentPollID(); public abstract bool DeletePoll(int pollID); public abstract bool ArchivePoll(int pollID); public abstract bool UpdatePoll(PollDetails poll); public abstract int InsertPoll(PollDetails poll); public abstract bool InsertVote(int optionID); // methods that work with poll options public abstract List<PollOptionDetails> GetOptions(int pollID); public abstract PollOptionDetails GetOptionByID(int optionID); public abstract bool DeleteOption(int optionID); public abstract bool UpdateOption(PollOptionDetails option); public abstract int InsertOption(PollOptionDetails option);
Finally, the class has a few concrete protected methods, which populate single or multiple instances of the PollDetails and PollOptionDetails custom entity classes from the input IDataReader:
/// <summary>Returns a new PollDetails instance filled with the DataReader's /// current record data </summary> protected virtual PollDetails GetPollFromReader(IDataReader reader) { return new PollDetails( (int)reader["PollID"], (DateTime)reader["AddedDate"], reader["AddedBy"].ToString(), reader["QuestionText"].ToString(), (bool)reader["IsCurrent"], (bool)reader["IsArchived"], (reader["ArchivedDate"] == DBNull.Value ? DateTime.MinValue : (DateTime)reader["ArchivedDate"]), (reader["Votes"] == DBNull.Value ? 0 : (int)reader["Votes"])); } /// <summary>Returns a collection of PollDetails objects with the data read from /// the input DataReader</summary> protected virtual List<PollDetails> GetPollCollectionFromReader( IDataReader reader) { List<PollDetails> polls = new List<PollDetails>(); while (reader.Read()) polls.Add(GetPollFromReader(reader)); return polls; } /// <summary>Returns a new PollOptionDetails instance filled with the /// DataReader's current record data</summary> protected virtual PollOptionDetails GetOptionFromReader(IDataReader reader) { PollOptionDetails option = new PollOptionDetails( (int)reader["OptionID"], (DateTime)reader["AddedDate"], reader["AddedBy"].ToString(), (int)reader["PollID"], reader["OptionText"].ToString(), (int)reader["Votes"], Convert.ToDouble(reader["Percentage"])); return option; } /// <summary>Returns a collection of PollOptionDetails objects with the data /// read from the input DataReader</summary> protected virtual List<PollOptionDetails> GetOptionCollectionFromReader( IDataReader reader) { List<PollOptionDetails> options = new List<PollOptionDetails>(); while (reader.Read()) options.Add(GetOptionFromReader(reader)); return options; } }
In this chapter I won't show the code for the entity classes, as they are just wrapper classes for all the retrieved database fields. The information in the diagram in Figure 6-2 is enough to completely define the class. I won't show the SQL Server provider either, as it is just a wrapper for the stored procedures listed and implemented earlier, and their structure is similar to the DAL code shown in the previous chapter. Consult the code download for this book to see all of the code.
This section describes one of the two BLL classes — namely, MB.TheBeerHouse.BLL.Polls.Poll, which is found in the ~/App_Code/BLL/Polls/Poll.cs file. The Option class won't be presented here because it is similar to Poll in structure, but shorter and simpler. The Poll class begins with the definition of the wrapper properties for the data that will come from the DAL in the form of a PollDetails instance, and with a constructor that takes all the values to initialize the properties:
public class Poll : BasePoll { private string _questionText = ""; public string QuestionText { get { return _questionText; } private set { _questionText = value; } } private bool _isCurrent = false; public bool IsCurrent { get { return _isCurrent; } private set { _isCurrent = value; } } private bool _isArchived = false; public bool IsArchived { get { return _isArchived; } private set { _isArchived = value; } } private DateTime _archivedDate = DateTime.MinValue; public DateTime ArchivedDate { get { return _archivedDate; } private set { _archivedDate = value; } } private int _votes = 0; public int Votes { get { return _votes; } private set { _votes = value; } } private List<Option> _options = null; public List<Option> Options { get { if (_options == null) _options = Option.GetOptions(this.ID); return _options; } } public Poll(int id, DateTime addedDate, string addedBy, string questionText, bool isCurrent, bool isArchived, DateTime archivedDate, int votes) { this.ID = id; this.AddedDate = addedDate; this.AddedBy = addedBy; this.QuestionText = questionText; this.IsCurrent = isCurrent; this.IsArchived = isArchived; this.ArchivedDate = archivedDate; this.Votes = votes; }
The only property that does more than just wrap a private field is Options, which returns the list of the poll's response options. It implements a simple form of the lazy load pattern, by actually retrieving the options only when they're required, and then it saves them in a local field in case they're requested again. The class has a number of instance methods (Delete, Update, Archive, and Vote) that forward the call to the corresponding static methods you'll see shortly, and pass the value of the instance properties as parameters:
public bool Delete() { bool success = Poll.DeletePoll(this.ID); if (success) this.ID = 0; return success; } public bool Update() { return Poll.UpdatePoll(this.ID, this.QuestionText, this.IsCurrent); } public bool Archive() { bool success = Poll.ArchivePoll(this.ID); if (success) { this.IsCurrent = false; this.IsArchived = true; this.ArchivedDate = DateTime.Now; } return success; } public bool Vote(int optionID) { bool success = Poll.VoteOption(this.ID, optionID); if (success) this.Votes += 1; return success; }
A couple of static properties return the ID of the current poll, and a Poll instance represents it. The CurrentPollID property caches the ID after retrieving it so that it doesn't have to request it every time if the current poll doesn't change. The current poll will probably be shown on every page of the site (if you plug it into the master page's left- or right-hand column), and by using caching you will save a SQL query for every page hit. Consider that there may be many requests per second, and you'll see why using caching in this situation can improve performance considerably! Here's the code:
public static int CurrentPollID { get { int pollID = -1; string key = "Polls_Poll_Current"; if (BasePoll.Settings.EnableCaching && BizObject.Cache[key] != null) { pollID = (int)BizObject.Cache[key]; } else { pollID = SiteProvider.Polls.GetCurrentPollID(); BasePoll.CacheData(key, pollID); } return pollID; } } public static Poll CurrentPoll { get { return GetPollByID(CurrentPollID); } }
Now we'll cover the static methods, which forward the call to the corresponding DAL provider method to actually retrieve and modify the data, but these will also add caching and wrap the results in instances of the Poll class itself. The GetPolls method has a couple of overloaded versions: one takes two parameters to include or exclude the active and archived polls, and the other version has no parameters and just forwards the call to the first method by passing true for both parameters to include both current and archived polls. The structure of GetPolls and GetPollByID is quite similar to the CurrentPollID property just seen: They first check whether the request's data is already in the cache. If so, they retrieve it from there; otherwise, they make a call to the DAL to fetch the data, save it in the cache for later requests, and return it (following the same lazy load design pattern you've seen already):
/// <summary> /// Returns a collection with all polls /// </summary> public static List<Poll> GetPolls() { return GetPolls(true, true); } public static List<Poll> GetPolls(bool includeActive, bool includeArchived) { List<Poll> polls = null; string key = "Polls_Polls_" + includeActive.ToString() + "_" + includeArchived.ToString(); if (BasePoll.Settings.EnableCaching && BizObject.Cache[key] != null) { polls = (List<Poll>)BizObject.Cache[key]; } else { List<PollDetails> recordset = SiteProvider.Polls.GetPolls(includeActive, includeArchived); polls = GetPollListFromPollDetailsList(recordset); BasePoll.CacheData(key, polls); } return polls; } /// <summary> /// Returns a Poll object with the specified ID /// </summary> public static Poll GetPollByID(int pollID) { Poll poll = null; string key = "Polls_Poll_" + pollID.ToString(); if (BasePoll.Settings.EnableCaching && BizObject.Cache[key] != null) { poll = (Poll)BizObject.Cache[key]; } else { poll = GetPollFromPollDetails(SiteProvider.Polls.GetPollByID(pollID)); BasePoll.CacheData(key, poll); } return poll; }
Note how the cache key is built because it will be important when you have to purge specific data. While the GetXXX methods cache their data, the methods that add, edit, or delete data will purge data from the cache, because it becomes stale at that time. In particular, the UpdatePoll method first performs the update for a specified poll, which causes that poll's cached data to become stale, and the list of active polls (you don't need to purge the archived polls because they are not editable). If the poll is also being set as the current one, the ID of the current poll is also removed from the cache. Following is the code:
/// <summary>Updates an existing poll</summary> public static bool UpdatePoll(int id, string questionText, bool isCurrent) { PollDetails record = new PollDetails(id, DateTime.Now, "", questionText, isCurrent, false, DateTime.Now, 0); bool ret = SiteProvider.Polls.UpdatePoll(record); BizObject.PurgeCacheItems("polls_polls_true"); BizObject.PurgeCacheItems("polls_poll_" + id.ToString()); if (isCurrent) BizObject.PurgeCacheItems("polls_poll_current"); return ret; }
When a poll is deleted, you have to purge all polls from the cache because you can't know whether the poll being deleted is an active or archived poll without executing a query to the database. As an alternative, you may want to run the query here and purge only the affected polls. This method also raises the RecordDeletedEvent developed in Chapter 3, so that this operation is logged by the provider specified in the web.config file (we're using the SqlWebEventProvider in this sample site, to log events to the site's SQL Server database):
/// <summary>Deletes an existing poll</summary> public static bool DeletePoll(int id) { bool ret = SiteProvider.Polls.DeletePoll(id); new RecordDeletedEvent("poll", id, null).Raise(); BizObject.PurgeCacheItems("polls_polls"); BizObject.PurgeCacheItems("polls_poll_" + id.ToString()); BizObject.PurgeCacheItems("polls_poll_current"); return ret; }
When a poll is archived, the lists containing both the active and archived polls are purged from the cache, as they will both change (the poll being archived will no longer be part of the active polls and will become part of the archived polls):
/// <summary>Archive an existing poll</summary> public static bool ArchivePoll(int id) { bool ret = SiteProvider.Polls.ArchivePoll(id); BizObject.PurgeCacheItems("polls_polls"); BizObject.PurgeCacheItems("polls_poll_" + id.ToString()); BizObject.PurgeCacheItems("polls_poll_current"); return ret; }
When the user submits a vote for a response option, the poll receiving the vote and the list with active polls are both purged, because the poll's Votes field will change. The list containing poll response options is also removed, of course, as their Votes and Percentage fields will change:
/// <summary>Votes for a poll option</summary> public static bool VoteOption(int pollID, int optionID) { bool ret = SiteProvider.Polls.InsertVote(optionID); BizObject.PurgeCacheItems("polls_polls_true"); BizObject.PurgeCacheItems("polls_poll_" + pollID.ToString()); BizObject.PurgeCacheItems("polls_options_" + pollID.ToString()); return ret; }
When a new poll is inserted, the list containing active polls is removed from the cache, and the ID for the current poll if the new poll is being set as the current one:
/// <summary>Creates a new poll</summary> public static int InsertPoll(string questionText, bool isCurrent) { PollDetails record = new PollDetails(0, DateTime.Now, BizObject.CurrentUserName, questionText, isCurrent, false, DateTime.Now, 0); int ret = SiteProvider.Polls.InsertPoll(record); BizObject.PurgeCacheItems("polls_polls_true"); if (isCurrent) BizObject.PurgeCacheItems("polls_poll_current"); return ret; }
The class ends with two private helper methods that take a single instance of PollDetails or a collection of them, and return an instance of Poll, or a collection of Poll objects, respectively:
/// <summary>Returns a Poll object filled with the data taken from /// the input PollDetails</summary> private static Poll GetPollFromPollDetails(PollDetails record) { if (record == null) return null; else { return new Poll(record.ID, record.AddedDate, record.AddedBy, record.QuestionText, record.IsCurrent, record.IsArchived, record.ArchivedDate, record.Votes); } } /// <summary>Returns a list of Comment objects filled with the data taken from /// the input list of CommentDetails</summary> private static List<Poll> GetPollListFromPollDetailsList( List<PollDetails> recordset) { List<Poll> polls = new List<Poll>(); foreach (PollDetails record in recordset) polls.Add(GetPollFromPollDetails(record)); return polls; } }
Now it's time to build the user interface: the administration page, the poll box user control, and the archive page.
This page, located under the ~/Admin folder, allows the administrator and editors to add, delete, and edit any active poll and its response options, review their votes, and archive a poll when it is no longer wanted as active. Polls and options are all managed in a single page: There is a GridView that lists the active polls, and below it is a DetailsView for inserting new polls or editing the selected one; there's also another GridView that lists the options for the selected poll, and another DetailsView for inserting new options for the selected poll, or editing the selected option. Figure 6-6 is a screenshot of the page when editing an existing poll.
The two DetailsView controls are in insert mode by default. When a row in their respective master grid is selected, they switch to edit mode, so the selected item can be modified. To change back to insert mode the user can either click Update or Cancel, according to whether she actually wants to persist the changes or just end the edit mode. The GridView and DetailsView controls for the options will be visible only when a poll is selected, as an option can't exist without a parent poll. Figure 6-7 shows the page in design mode from inside the Visual Studio 2005's web designer. Note that all controls have a white background and back text (the default appearance) and don't show up with the color scheme of the controls of the picture above. This is because the colors and other appearance settings are stored in the theme's skin files (applied at runtime), used to create a consistent look and feel among all pages, and to make it easy to change the visual styles for the entire site by changing a single file.
The screenshot should give you a good idea of how the page is composed and how the various controls are displayed on the form. In subsequent screenshots, I'll only show you the code taken from the Source View, instead of showing all the steps to visually create the interface. First, there's the ObjectDataSource for the GridView listing the active polls. It references the MB.TheBeerHouse.BLL.Polls.Poll class and uses the GetPolls method to retrieve the polls, and DeletePoll to delete one of them. The parameters for the GetPolls select method are set to true and false, to include active polls and exclude archived ones:
<asp:ObjectDataSource ID="objPolls" runat="server" SelectMethod="GetPolls" TypeName="MB.TheBeerHouse.BLL.Polls.Poll" DeleteMethod="DeletePoll"> <DeleteParameters> <asp:Parameter Name="id" Type="Int32" /> </DeleteParameters> <SelectParameters> <asp:Parameter DefaultValue="true" Name="includeActive" Type="Boolean" /> <asp:Parameter DefaultValue="false" Name="includeArchived" Type="Boolean" /> </SelectParameters> </asp:ObjectDataSource>
The GridView that follows it uses the ObjectDataSource above as data source, and defines a number of columns to show the poll's ID, question text, and number of votes; and a checkbox is shown if the poll is the current one. The checkbox is not automatically rendered by a CheckBoxField but is an image shown from inside a TemplateField whose Visible property is bound to the poll's IsCurrent property. On the right side of the grid are three columns with buttons to select, archive, and delete a poll. The image representing a pencil in the figures is used to change a row to edit mode, which is actually a Select command (they are selected in the GridView and edited in the DetailsView). The Select and Edit commands are rendered by two CommandField columns, according to the ShowSelectButton and ShowDeleteButton properties. The Archive command is a custom command instead, created with a ButtonField column whose events will be handled manually and not by the associated ObjectDataSource. Here's the complete code for the GridView:
<asp:GridView ID="gvwPolls" runat="server" AutoGenerateColumns="False" DataSourceID="objPolls" Width="100%" DataKeyNames="ID" OnRowCreated="gvwPolls_RowCreated" OnRowDeleted="gvwPolls_RowDeleted" OnSelectedIndexChanged="gvwPolls_SelectedIndexChanged" OnRowCommand="gvwPolls_RowCommand"> <Columns> <asp:BoundField DataField="ID" HeaderText="ID" HeaderStyle-HorizontalAlign="Left" ReadOnly="True" /> <asp:BoundField DataField="QuestionText" HeaderText="Poll" HeaderStyle-HorizontalAlign="Left" /> <asp:BoundField DataField="Votes" ReadOnly="True" HeaderText="Votes" ItemStyle-HorizontalAlign="center" /> <asp:TemplateField HeaderText="Is Current" ItemStyle-HorizontalAlign="center"> <ItemTemplate> <asp:Image runat="server" ID="imgIsCurrent" ImageUrl="~/Images/OK.gif" Visible='<%# Eval("IsCurrent") %>' /> </ItemTemplate> </asp:TemplateField> <asp:CommandField ButtonType="Image" SelectImageUrl="~/Images/Edit.gif" SelectText="Edit poll" ShowSelectButton="True"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:CommandField> <asp:ButtonField ButtonType="Image" ImageUrl="~/Images/Folder.gif" CommandName="Archive"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:ButtonField> <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif" DeleteText="Delete poll"ShowDeleteButton="True"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:CommandField> </Columns> <EmptyDataTemplate><b>No polls to show</b></EmptyDataTemplate> </asp:GridView>
Note that the grid also defines an <EmptyDataTemplate> section, with a message that will be displayed when the GetPolls Select method returns no polls to display.
Following is the ObjectDataSource used by the DetailsView under the grid to insert and update polls, by means of the InsertPoll and UpdatePoll static methods of the Poll business class. A Select command is also required to show the details of the row selected in the master GridView. GetPollByID is called by the GridView's Select method:
<asp:ObjectDataSource ID="objCurrPoll" runat="server" InsertMethod="InsertPoll" SelectMethod="GetPollByID" UpdateMethod="UpdatePoll" TypeName="MB.TheBeerHouse.BLL.Polls.Poll"> <SelectParameters> <asp:ControlParameter ControlID="gvwPolls" Name="pollID" PropertyName="SelectedValue" Type="Int32" /> </SelectParameters> <UpdateParameters> <asp:Parameter Name="id" Type="Int32" /> <asp:Parameter Name="questionText" Type="String" /> <asp:Parameter Name="isCurrent" Type="Boolean" /> </UpdateParameters> <InsertParameters> <asp:Parameter Name="questionText" Type="String" /> <asp:Parameter Name="isCurrent" Type="Boolean" /> </InsertParameters> </asp:ObjectDataSource>
The DetailsView shows all fields of the poll, including AddedDate and AddedBy, which are not included in the GridView due to space constraints. However, only the QuestionText and the IsCurrent fields are editable; the other fields are read-only in edit mode, and not visible in insert mode. The QuestionText field is defined by means of a TemplateField, so it's possible to insert a RequiredFieldValidator in the EditItemTemplate section, to ensure that the textbox is populated by the user:
<asp:DetailsView ID="dvwPoll" runat="server" AutoGenerateRows="False" DataSourceID="objCurrPoll" Width="100%" AutoGenerateEditButton="True" AutoGenerateInsertButton="True" HeaderText="Poll Details" DataKeyNames="ID" DefaultMode="Insert" OnItemCommand="dvwPoll_ItemCommand" OnItemInserted="dvwPoll_ItemInserted" OnItemUpdated="dvwPoll_ItemUpdated"OnItemCreated="dvwPoll_ItemCreated"> <FieldHeaderStyle Width="100px" /> <Fields> <asp:BoundField DataField="ID" HeaderText="ID" ReadOnly="True" InsertVisible="False" /> <asp:BoundField DataField="AddedDate" HeaderText="AddedDate" InsertVisible="False" ReadOnly="True" /> <asp:BoundField DataField="AddedBy" HeaderText="AddedBy" InsertVisible="False" ReadOnly="True" /> <asp:BoundField DataField="Votes"HeaderText="Votes" ReadOnly="True" InsertVisible="False" /> <asp:TemplateField HeaderText="Question"> <ItemTemplate> <asp:Label ID="lblQuestion" runat="server" Text='<%# Eval("QuestionText") %>'></asp:Label> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtQuestion" runat="server" Text='<%# Bind("QuestionText") %>' MaxLength="256" Width="100%" /> <asp:RequiredFieldValidator ID="valRequireQuestion" runat="server" ControlToValidate="txtQuestion"SetFocusOnError="true" Text="The Question field is required."Display="Dynamic" ValidationGroup="Poll"></asp:RequiredFieldValidator> </EditItemTemplate> </asp:TemplateField> <asp:CheckBoxField DataField="IsCurrent" HeaderText="Is Current" /> </Fields> </asp:DetailsView>
The page then declares another GridView and another DetailsView to list and edit response options for a poll. They are both declared within a Panel control, so that they can be easily shown or hidden when a parent is, or is not, selected. Their definition is similar to the code seen for the polls — the only difference is that GridView, displaying the options, is the master control for the DetailsView used to edit and insert options. But this GridView itself is a detail control for the polls GridView:
<asp:Panel runat="server" ID="panOptions" Visible="false" Width="100%"> <asp:GridView ID="gvwOptions" runat="server" AutoGenerateColumns="False" DataSourceID="objOptions" DataKeyNames="ID" Width="100%" OnRowCreated="gvwOptions_RowCreated"OnRowDeleted="gvwOptions_RowDeleted" OnSelectedIndexChanged="gvwOptions_SelectedIndexChanged"> <Columns> <asp:BoundField DataField="OptionText" HeaderText="Option"> <HeaderStyle HorizontalAlign="Left" /> </asp:BoundField> <asp:BoundField DataField="Votes" HeaderText="Votes" ReadOnly="True"> <ItemStyle HorizontalAlign="Center" /> </asp:BoundField> <asp:BoundField DataField="Percentage" DataFormatString="{0:N1}" HtmlEncode="False"HeaderText="%" ReadOnly="True"> <ItemStyle HorizontalAlign="Center" /> </asp:BoundField> <asp:CommandField ButtonType="Image" SelectImageUrl="~/Images/Edit.gif" SelectText="Edit option" ShowSelectButton="True"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:CommandField> <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif" DeleteText="Delete option" ShowDeleteButton="True"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:CommandField> </Columns> <EmptyDataTemplate> <b>No options to show for the selected poll</b></EmptyDataTemplate> </asp:GridView> <asp:ObjectDataSource ID="objOptions" runat="server" DeleteMethod="DeleteOption" SelectMethod="GetOptions" TypeName="MB.TheBeerHouse.BLL.Polls.Option"> <DeleteParameters> <asp:Parameter Name="id" Type="Int32" /> </DeleteParameters> <SelectParameters> <asp:ControlParameter ControlID="gvwPolls" Name="pollID" PropertyName="SelectedValue" Type="Int32" /> </SelectParameters> </asp:ObjectDataSource> <p></p> <asp:DetailsView ID="dvwOption" runat="server" AutoGenerateRows="False" DataSourceID="objCurrOption" Width="100%" AutoGenerateEditButton="True" AutoGenerateInsertButton="True" HeaderText="Option Details" DataKeyNames="ID" DefaultMode="Insert" OnItemCommand="dvwOption_ItemCommand" OnItemInserted="dvwOption_ItemInserted" OnItemUpdated="dvwOption_ItemUpdated" OnItemCreated="dvwOption_ItemCreated"> <FieldHeaderStyle Width="100px" /> <Fields> <asp:BoundField DataField="ID" HeaderText="ID" ReadOnly="True" InsertVisible="False" /> <asp:BoundField DataField="AddedDate" HeaderText="AddedDate" InsertVisible="False" ReadOnly="True" /> <asp:BoundField DataField="AddedBy" HeaderText="AddedBy" InsertVisible="False" ReadOnly="True" /> <asp:BoundField DataField="Votes" HeaderText="Votes" ReadOnly="True" InsertVisible="False" /> <asp:BoundField DataField="Percentage" DataFormatString="{0:N1}" HtmlEncode="False" HeaderText="Percentage" ReadOnly="True" InsertVisible="False" /> <asp:TemplateField HeaderText="Option"> <ItemTemplate> <asp:Label ID="lblOption" runat="server" Text='<%# Eval("OptionText") %>'></asp:Label> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtOption" runat="server" Text='<%# Bind("OptionText") %>' MaxLength="256" Width="100%"></asp:TextBox> <asp:RequiredFieldValidator ID="valRequireOption" runat="server" ControlToValidate="txtOption" SetFocusOnError="true" Text="The Option field is required." Display="Dynamic" ValidationGroup="Option"></asp:RequiredFieldValidator> </EditItemTemplate> </asp:TemplateField> </Fields> </asp:DetailsView> <asp:ObjectDataSource ID="objCurrOption" runat="server" InsertMethod="InsertOption" SelectMethod="GetOptionByID" TypeName="MB.TheBeerHouse.BLL.Polls.Option" UpdateMethod="UpdateOption"> <SelectParameters> <asp:ControlParameter ControlID="gvwOptions" Name="optionID" PropertyName="SelectedValue" Type="Int32" /> </SelectParameters> <UpdateParameters> <asp:Parameter Name="id" Type="Int32" /> <asp:Parameter Name="optionText" Type="String" /> </UpdateParameters> <InsertParameters> <asp:ControlParameter ControlID="gvwPolls" Name="pollID" PropertyName="SelectedValue" Type="Int32" /> <asp:Parameter Name="optionText" Type="String" /> </InsertParameters> </asp:ObjectDataSource> </asp:Panel>
The RequiredFieldValidator in this last block of code has the ValidationGroup property set to "Option", while the property was set to "Poll" in the block showing the code for the Poll DetailsView. They have different values to define two separate "logical" forms, so the input controls of the second form are not validated when you submit the page by clicking a button of the first logical form, and vice versa. To make this work, however, you need to set the same properties to the same values for the Submit buttons of each logical form. This can't be done from the ASPX page itself, though, because the Button or LinkButton controls are not declared explicitly, but rather are created dynamically at runtime by the CommandField columns. Because of this, the ValidationGroup property for these buttons must be set programmatically from the code-behind, when the row and its controls are created, (i.e., from the DetailsView's ItemCreated event). The next section explains the code in the page's .cs code-behind file, which performs this operation, among other things.
In the code-behind for this page you won't find any code to retrieve and bind data to the controls, or to edit or delete polls and options, as these operations are all done automatically by the four ObjectDataSource controls defined on the page. Instead, from this file you handle some events of the GridView and the DetailsView controls to do the following things:
Put the DetailsView for the polls into insert mode, and hide the controls that display and edit the options when there's no poll selected or when the currently selected poll is deselected. This happens when a new poll is inserted or an existing poll is deleted, archived, or updated, and when the user clicks Cancel while editing a poll:
private void DeselectPoll() { gvwPolls.SelectedIndex = -1; gvwPolls.DataBind(); dvwPoll.ChangeMode(DetailsViewMode.Insert); panOptions.Visible = false; } protected void gvwPolls_RowDeleted(object sender, GridViewDeletedEventArgs e) { DeselectPoll(); } protected void dvwPoll_ItemInserted(object sender, DetailsViewInsertedEventArgs e) { DeselectPoll(); } protected void dvwPoll_ItemUpdated(object sender, DetailsViewUpdatedEventArgs e) { DeselectPoll(); } protected void dvwPoll_ItemCommand(object sender, DetailsViewCommandEventArgs e) { if (e.CommandName == "Cancel") DeselectPoll(); }
Put the DetailsView for the options into insert mode when a new option is inserted or an existing option is deleted or updated, and when the user clicks Cancel while editing an option:
private void DeselectOption() { gvwOptions.SelectedIndex = -1; gvwOptions.DataBind(); dvwOption.ChangeMode(DetailsViewMode.Insert); } protected void dvwOption_ItemInserted(object sender, DetailsViewInsertedEventArgs e) { DeselectOption(); } protected void dvwOption_ItemUpdated(object sender, DetailsViewUpdatedEventArgs e) { DeselectOption(); } protected void gvwOptions_RowDeleted(object sender, GridViewDeletedEventArgs e) { DeselectOption(); } protected void dvwOption_ItemCommand(object sender, DetailsViewCommandEventArgs e) { if (e.CommandName == "Cancel") DeselectOption(); }
Show the controls that display and edit the options when a poll is selected, which is processed from inside the gvwPolls GridView's SelectedIndexChanged event handler; and put the DetailsView for the selected poll into edit mode:
protected void gvwPolls_SelectedIndexChanged(object sender, EventArgs e) { dvwPoll.ChangeMode(DetailsViewMode.Edit); panOptions.Visible = true; }
Put the DetailsView for the selected option into edit mode from inside the gvwOptions GridView's SelectedIndexChanged event handler:
protected void gvwOptions_SelectedIndexChanged(object sender, EventArgs e) { dvwOption.ChangeMode(DetailsViewMode.Edit); }
Archive a poll when the respective image link is clicked, which is processed from inside the gvwPolls GridView's RowCommand event handler:
protected void gvwPolls_RowCommand(object sender, GridViewCommandEventArgs e) { if (e.CommandName == "Archive") { int pollID = Convert.ToInt32( gvwPolls.DataKeys[Convert.ToInt32(e.CommandArgument)][0]); MB.TheBeerHouse.BLL.Polls.Poll.ArchivePoll(pollID); DeselectPoll(); } }
Add a JavaScript confirmation pop-up when the user clicks the commands to delete or update a poll, or delete an option. This is done from the respective grid's RowCreated event handler, from which you get a reference to the LinkButton control found in a specified column, and then add the JavaScript by means of its OnClientClick property:
protected void gvwPolls_RowCreated(object sender, GridViewRowEventArgs e) { if (e.Row.RowType == DataControlRowType.DataRow) { ImageButton btnArchive = e.Row.Cells[5].Controls[0] as ImageButton; btnArchive.OnClientClick = @" if (confirm('Are you sure you want to archive this poll?') == false) return false;"; ImageButton btnDelete = e.Row.Cells[6].Controls[0] as ImageButton; btnDelete.OnClientClick = @" if (confirm('Are you sure you want to delete this poll?') == false) return false;"; } } protected void gvwOptions_RowCreated(object sender, GridViewRowEventArgs e) { if (e.Row.RowType == DataControlRowType.DataRow) { ImageButton btn = e.Row.Cells[4].Controls[0] as ImageButton; btn.OnClientClick = @" if (confirm('Are you sure you want to delete this option?') == false) return false;"; } }
Set the ValidationGroup property of the Update buttons of the two Poll and Option logical forms to the proper values (i.e., the values used for the RequiredFieldValidator in the .aspx file). This is done from the ItemCreated event handlers of the two DetailsView controls. Because the ID for those controls is created dynamically, you don't know it at design-time, and thus you can't use the FindControl method to easily reference them. Instead, you loop thorough all the controls in the last row of the DetailsView (i.e., the command bar row) and stop when you find a LinkButton with the CommandName property set to "Insert" or "Update":
protected void dvwPoll_ItemCreated(object sender, EventArgs e) { foreach (Control ctl in dvwPoll.Rows[dvwPoll.Rows.Count - 1].Controls[0].Controls) { if (ctl is LinkButton) { LinkButton lnk = ctl as LinkButton; if (lnk.CommandName == "Insert" || lnk.CommandName == "Update") lnk.ValidationGroup = "Poll"; } } } protected void dvwOption_ItemCreated(object sender, EventArgs e) { foreach (Control ctl in dvwOption.Rows[dvwOption.Rows.Count - 1].Controls[0].Controls) { if (ctl is LinkButton) { LinkButton lnk = ctl as LinkButton; if (lnk.CommandName == "Insert" || lnk.CommandName == "Update") lnk.ValidationGroup = "Option"; } } }
Referring to Figure 6-6, you may have noticed that there's no way to cast a vote — this functionality hasn't been developed yet. In order to be able to test the grid on the administration page, you would need to set the votes for some options in the tbh_PollOptions table manually, using VS2005's Server Explorer interface. The administration page is now complete, and we will cover the implementation of the end-user part of the poll module.
Now it's time to build the PollBox user control that you'll plug into the site's common layout (i.e., the master page). The PollBox.ascx user control is created under the ~/Controls folder, together with all other user controls.
This user control can be divided into four parts. The first defines a panel with an image and a label for the configurable header text. This content is placed into a Panel so that it can be hidden if the ShowHeader property is set to false. It also defines another label for the poll's question text:
<%@ Control Language="C#" AutoEventWireup="true" CodeFile="PollBox.ascx.cs" Inherits="PollBox" %> <div class="pollbox"> <asp:Panel runat="server" ID="panHeader"> <div class="sectiontitle"> <asp:Image ID="imgArrow" runat="server" ImageUrl="~/images/arrowr.gif" style="float: left; margin-left: 3px; margin-right: 3px;"/> <asp:Label runat="server" ID="lblHeader"></asp:Label> </div> </asp:Panel> <div class="pollcontent"> <asp:Label runat="server" ID="lblQuestion" CssClass="pollquestion"></asp:Label>
The second part is a Panel to show when the poll box allows the user to vote (i.e., when it detects that the poll being shown is not archived, and the user has not already voted for it). The Panel contains a RadioButtonList to list the options, a RequiredFieldValidator that ensures that at least one option is selected when the form is submitted, and the button to do the postback:
<asp:Panel runat="server" ID="panVote"> <div class="polloptions"> <asp:RadioButtonList runat="server" ID="optlOptions" DataTextField="OptionText" DataValueField="ID" /> <asp:RequiredFieldValidator ID="valRequireOption" runat="server" ControlToValidate="optlOptions" SetFocusOnError="true" Text="You must select an option."ToolTip="You must select an option" Display="Dynamic" ValidationGroup="PollVote"></asp:RequiredFieldValidator> </div> <asp:Button runat="server" ID="btnVote" ValidationGroup="PollVote" Text="Vote" OnClick="btnVote_Click" /> </asp:Panel>
The third part defines the Panel to be displayed when the control detects that the user has already voted for the current poll. In this situation the control displays the results, which is done by means of a Repeater that outputs the option text and the number of votes it has received. It also creates a <div> element whose width style attribute is set to the option's Percentage value, so that the user will get a visual representation of the vote percentage, in addition to seeing the percentage as a number:
<asp:Panel runat="server" ID="panResults"> <div class="polloptions"> <asp:Repeater runat="server" ID="rptOptions"> <ItemTemplate> <%# Eval("OptionText") %> <small>(<%# Eval("Votes") %> vote(s) - <%# Eval("Percentage", "{0:N1}") %>%)</small> <br /> <div class="pollbar" style="width: <%# Eval("Percentage")%>%"> </div> </ItemTemplate> </asp:Repeater> <br /> <b>Total votes: <asp:Label runat="server" ID="lblTotalVotes" /></b> </div> </asp:Panel>
Finally, the last section of the control defines a link to the archive page, which can be hidden by means of the control's ShowArchiveLink custom property, plus a couple of closing tags for <div> elements opened earlier to associate some CSS styles to the various parts of the control:
<asp:HyperLink runat="server" ID="lnkArchive" NavigateUrl="~/ArchivedPolls.aspx" Text="Archived Polls" /> </div> </div>
The control's code-behind file begins by defining all those custom properties described in the "Design" section. Most of these properties are just wrappers for the Text or Visible properties of inner labels and panels, so they don't need their values persisted:
public partial class PollBox : System.Web.UI.UserControl { public bool ShowArchiveLink { get { return lnkArchive.Visible; } set { lnkArchive.Visible = value; } } public bool ShowHeader { get { return panHeader.Visible; } set { panHeader.Visible = value; } } public string HeaderText { get { return lblHeader.Text; } set { lblHeader.Text = value; } } public bool ShowQuestion { get { return lblQuestion.Visible; } set { lblQuestion.Visible = value; } }
The PollID property, instead, does not wrap any other property, and therefore its value is manually stored and retrieved to and from the control's state, as part of the control's ViewState collection. As already shown in previous chapters, this is done by overriding the control's LoadControlState and SaveControlState methods and registering the control to specify that it requires the control state, from inside the Init event handler:
private int _pollID = -1; public int PollID { get { return _pollID; } set { _pollID = value; } } protected void Page_Init(object sender, EventArgs e) { this.Page.RegisterRequiresControlState(this); } protected override void LoadControlState(object savedState) { object[] ctlState = (object[])savedState; base.LoadControlState(ctlState[0]); this.PollID = (int)ctlState[1]; } protected override object SaveControlState() { object[] ctlState = new object[2]; ctlState[0] = base.SaveControlState(); ctlState[1] = this.PollID; return ctlState; }
The control can be shown because it is explicitly defined on the page, or because it is dynamically created by some template-based control, such as Repeater, DataList, DataGrid, GridView, and DetailsView. In the first case, the code that loads and shows the response options (in either edit or display mode) will be run from the control's Load event handler. Otherwise, it will run from the control's DataBind method, which you can override. The code itself is placed in a separate method, DoBinding, and it's called from these two methods, as follows:
protected void Page_Load(object sender, EventArgs e) { if (!this.IsPostBack) DoBinding(); } public override void DataBind() { base.DataBind(); // with the PollID set, do the actual binding DoBinding(); }
Note that in the DataBind method, the base version of DataBind is called before executing the custom binding code of DoBinding. The call to the base version, in turn, makes a call to the control's standard OnDataBinding method, which parses and evaluates the control's expressions. This is necessary because when the control is placed into a template it will have the PollID property bound to some expression, and this binding expression must be evaluated before actually executing the DoBinding method, so that it will find the final PollID value.
The DoBinding method retrieves the data from the database (via the BLL), binds it to the proper RadioButtonList and the Repeater controls, and either shows or hides the options or results, depending on whether the user has already voted for the question being asked. However, before retrieving the poll and its options, it must check whether the PollID property is set to -1, in which case it must first retrieve the ID of the current poll:
protected void DoBinding() { panResults.Visible = false; panVote.Visible = false; int pollID = (this.PollID == -1 ? Poll.CurrentPollID : this.PollID); if (pollID > -1) { Poll poll = Poll.GetPollByID(pollID); if (poll != null) { lblQuestion.Text = poll.QuestionText; lblTotalVotes.Text = poll.Votes.ToString(); valRequireOption.ValidationGroup += poll.ID.ToString(); btnVote.ValidationGroup = valRequireOption.ValidationGroup; optlOptions.DataSource = poll.Options; optlOptions.DataBind(); rptOptions.DataSource = poll.Options; rptOptions.DataBind(); if (poll.IsArchived || GetUserVote(pollID) > 0) panResults.Visible = true; else panVote.Visible = true; } } }
To check whether the current user has already voted for the poll, a call to the GetUserVote method is made. If the method returns a value greater than 0, it means that a vote for the specified poll was found. You'll see the code for this method in a moment, but first consider the code executed when the Vote button is clicked. The button's Click event handler calls the Poll.VoteOption business method to add a vote for the specified option (whose ID is read from the RadioButtonList's SelectedValue), and then shows the results panel and hides the edit panel. In order to remember that the user has voted for this poll, you create a cookie named Vote_Poll{x}, where {x} is the ID of the poll. The cookie's value is the ID of the option the user has voted for. The cookie is created only if the VotingLockByCookie configuration property is set to true (the default) and the cookie's expiration is set to the current date plus the number of days also stored in the <polls> custom configuration element (15 by default). Finally, it saves the votes in the cache (unless the VotingLockByIP setting is set to false), to ensure that it will be remembered at least for the current user's session even if the client has his cookies turned off. The cache's key is defined as {y}_Vote_Poll{x}, where {y} is replaced by the client's IP address. This is necessary because the Cache is not user-specific like session state, and thus you need to create different keys for different users. Here's the code of the Click event handler:
protected void btnVote_Click(object sender, EventArgs e) { int pollID = (this.PollID == -1 ? Poll.CurrentPollID : this.PollID); // check that the user has not already voted for this poll int userVote = GetUserVote(pollID); if (userVote == 0) { // post the vote and then create a cookie to remember this user's vote userVote = Convert.ToInt32(optlOptions.SelectedValue); Poll.VoteOption(pollID, userVote); // hide the panel with the radio buttons, and show the results DoBinding(); panVote.Visible = false; panResults.Visible = true; DateTime expireDate = DateTime.Now.AddDays( Globals.Settings.Polls.VotingLockInterval); string key = "Vote_Poll" + pollID.ToString(); // save the result to the cookie if (Globals.Settings.Polls.VotingLockByCookie) { HttpCookie cookie = new HttpCookie(key, userVote.ToString()); cookie.Expires = expireDate; this.Response.Cookies.Add(cookie); } // save the vote also to the cache if (Globals.Settings.Polls.VotingLockByIP) { Cache.Insert( this.Request.UserHostAddress.ToString() + "_" + key, userVote); } } }
The final piece of code is the GetUserVote method discussed earlier, which takes the ID of a poll, and checks whether it finds a vote in a client's cookie or in the cache, according to the VotingLockByCookie and VotingLockByIP settings, respectively. If no vote is found in either place, 0 is returned, indicating that the current user has not yet voted for the specified poll:
protected int GetUserVote(int pollID) { string key = "Vote_Poll" + pollID.ToString(); string key2 = this.Request.UserHostAddress.ToString() + "_" + key; // check if the vote is in the cache if (Globals.Settings.Polls.VotingLockByIP && Cache[key2] != null) return (int)Cache[key2]; // if the vote is not in cache, check if there's a client-side cookie if (Globals.Settings.Polls.VotingLockByCookie) { HttpCookie cookie = this.Request.Cookies[key]; if (cookie != null) return int.Parse(cookie.Value); } return 0; } }
The PollBox user control is now ready, and you can finally plug it into any page. For this sample site we'll put it into the site's master page, so that the polls will be visible in all pages. As an example of adding more, you can add two PollBox instances to the master page: The first will have no PollID specified, so that it will dynamically use the current poll; and the second one has the PollID property set to a specific value so it can reference a different poll and has the ShowArchiveLink property set to false to hide the link to the archive page, as it's already shown by the first poll box. Here's the code:
<%@ Register Src="Controls/PollBox.ascx"TagName="PollBox" TagPrefix="mb" %> ... <mb:PollBox id="PollBox1" runat="server" HeaderText="Poll of the week" /> <mb:PollBox id="PollBox2" runat="server" HeaderText="More polls" PollID="18" ShowArchiveLink="False" />
Figure 6-8 shows the result: the home page with the two poll boxes displayed in the site's left-hand column. You can change the first poll simply by going to the administrative page and setting a different (existing or new) poll as the current one. If you want to change the second, you'll need to change the ID in the master page's source code file.
This is the last page that we'll develop for this module. It lists all archived polls, one per line, and when the user clicks one it has to expand and display its options and results. The page allows you to have multiple questions expanded at the same time if you prefer. It initially shows them in "collapsed" mode because you don't want to create a very long page, distracting users and making it hard for them to search for a particular question. Displaying the questions only when the page is first loaded produces a cleaner and more easily navigable page. If the current user is an administrator or an editor, she will also see command links on the right side of the listed polls to delete them. Figure 6-9 shows the page as seen by a normal anonymous user, which has expanded two of the three polls listed on the page.
If you compare the poll results on the page's central section with the results of the poll in the left-hand column, you'll notice that they look similar. Actually, they are nearly identical, except for the fact that in the former case the question text is shown as a link and not as bold text. As you can easily guess, the poll results rendered in the page's content section are created by PollBox controls, which have the ShowHeader, ShowQuestion, and ShowArchiveLink properties set to false. The link with the poll's text is created by a binding expression defined within a TemplateField column of a parent GridView control. The PollBox control itself is defined inside the sample template section and has its PollID property set to a binding expression that retrieves the PollID value from the Poll object being bound to every row, and is wrapped by a <div> that is hidden by default (it has the display style attribute set to none). When the user clicks the link, he doesn't navigate to another page, but executes a local JavaScript function that takes the name of a <div> (named after poll{x}, where {x} is the ID of a poll) and toggles its display state (if set to none, it sets it to an empty string to make it visible, and vice versa). Following is the code for the GridView and its templated column, the companion ObjectDataSource that references the Poll object's methods used to retrieve and delete the records, and the JavaScript function that hides and shows the <div> with the results:
<script type="text/javascript"> function toggleDivState(divName) { var ctl = window.document.getElementById(divName); if (ctl.style.display == "none") ctl.style.display = ""; else ctl.style.display = "none"; } </script> <div class="sectiontitle">Archived Polls</div> <p>Here is the complete list of archived polls run in the past. Click on the poll's question to see its results.</p> <asp:GridView ID="gvwPolls" runat="server" AutoGenerateColumns="False" DataSourceID="objPolls"Width="100%" DataKeyNames="ID" OnRowCreated="gvwPolls_RowCreated" ShowHeader="false"> <Columns> <asp:TemplateField> <ItemTemplate> <img src="images/arrowr2.gif" /> <a href="javascript:toggleDivState('poll<%# Eval("ID") %>');"> <%# Eval("QuestionText") %></a> <small>(archived on <%# Eval("ArchivedDate", "{0:d}") %>)</small> <div style="display: none;" id="poll<%# Eval("ID") %>"> <mb:PollBox id="PollBox1" runat="server" PollID='<%# Eval("ID") %>' ShowHeader="False" ShowQuestion="False" ShowArchiveLink="False" /> </div> </ItemTemplate> </asp:TemplateField> <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif" DeleteText="Delete poll" ShowDeleteButton="True"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:CommandField> </Columns> <EmptyDataTemplate><b>No polls to show</b></EmptyDataTemplate> </asp:GridView> <asp:ObjectDataSource ID="objPolls" runat="server" SelectMethod="GetPolls" TypeName="MB.TheBeerHouse.BLL.Polls.Poll" DeleteMethod="DeletePoll"> <DeleteParameters> <asp:Parameter Name="id" Type="Int32" /> </DeleteParameters> <SelectParameters> <asp:Parameter DefaultValue="false" Name="includeActive" Type="Boolean" /> <asp:Parameter DefaultValue="true" Name="includeArchived" Type="Boolean" /> </SelectParameters> </asp:ObjectDataSource>
In the GridView defined above, a CommandField column is declared to create a Delete command for each of the listed polls. However, this command must be visible only to users who belong to the Editors and Administrators roles. When the page loads, if the user is not authorized to delete polls, then the GridView's second column is hidden. Before doing this, however, you have to check whether the user is anonymous and, if so, whether the page is accessible to everyone or only to registered members. If the check fails, the RequestLogin method of the BasePage base class is called to redirect the user to the login page. Here's the code:
protected void Page_Load(object sender, EventArgs e) { if (!this.User.Identity.IsAuthenticated && !Globals.Settings.Polls.ArchiveIsPublic) { this.RequestLogin(); } gvwPolls.Columns[1].Visible = (this.User.Identity.IsAuthenticated && (this.User.IsInRole("Administrators") || this.User.IsInRole("Editors"))); }
The only other code in the code-behind file for this page is the event handler for the GridView's RowCreated event, from which you add the JavaScript confirmation pop-up to the Delete command buttons:
protected void gvwPolls_RowCreated(object sender, GridViewRowEventArgs e) { if (e.Row.RowType == DataControlRowType.DataRow) { ImageButton btn = e.Row.Cells[1].Controls[0] as ImageButton; btn.OnClientClick = @" if (confirm('Are you sure you want to delete this poll?') == false) return false;"; } }