Blogging

Design Your Own Weblog Application from Scratch Using ASP.NET, JavaScript, and OLE DB

Marco Bellinaso

Code download available at:Blogging.exe(151 KB)

This article assumes you're familiar with Visual Basic .NET and JavaScript

Level of Difficulty123

SUMMARY

The ASP.NET advanced templated controls, such as the DataList and DataGrid, are perfect for many data representation situations. However, when you need the flexibility to render a variety of layouts, the Repeater control is what you need. In this article the author builds a full-featured blog application to illustrate the use of the Repeater and DataList controls that render nested data in a master-detail relationship. He then discusses how to override the default implementations of these controls by adding some client-side JavaScript code that makes the blog more responsive and enhances its usability.

Contents

The Database Design
The Business Layer
Showing Messages and Child Comments
Selecting an Interval and Loading the Blog
Popup Calendar
Posting Comments
Administering the Blog
Validation for Multiple Virtual Forms
Conclusion

It seems like everyone wants a blog these days—I know I did. But I couldn't find any pre-built ASP.NET blog code with the features I wanted, so I built my own. The great thing about building your own blogging application is that you get lots of practice with ASP.NET server controls, such as the Repeater, DataList, and Calendar. A blog application may seem like a simple exercise at first, but in fact it requires you to implement many features you'd need in a typical reporting application, such as building and rendering master-detail relationships or editing and deleting records, hiding or showing content and controls based on the logged-in user, and managing input validation for multiple virtual forms on the same page. This article presents the design and implementation details of a Web log and explains techniques that can be easily applied to a variety of ASP.NET projects, whether they're built for business or simply for fun.

Before you start coding, you should decide what kind of blog you want to build, what features it will have, and how the data store will be designed. There are a number of features an effective blog will include. The blog's messages should be shown from the newest to the oldest. Multiple messages could be posted the same day, and they should be visually grouped together in a table or a box, but still recognizable as separate posts ordered chronologically. Also, the user should be able to select a time interval for the entries she wants to read. This is important because you don't want to retrieve old content that the user has already seen.

The user should be able to comment on any message, and the posted comments should be shown directly under their parent message so it's easy to follow the thread. In addition, the blog's owner should be able to post, edit, and delete messages and comments, while the user should only be able to read messages and post comments. To allow or prohibit posting and editing based on the user, some controls will need to be either shown or hidden, and will require some form of authentication.

The Database Design

Next, you must decide how you want to store the messages and comments. In this project, I used a SQL Server™ database, but my sample code also includes a Microsoft® Access database, in case that's your data store of choice. The database contains just two tables: one for the messages and the other for the comments. The messages table stores unique IDs, an optional title or headline, the message text, and the date and time that the message is posted. The comments table stores unique IDs, the ID of the message the comment refers to, the author's name, the author's e-mail address, the comment text, and the date and time the message is posted. Figure 1 shows the design of the tables.

Figure 1 Parent/Child Tables

Figure 1** Parent/Child Tables **

As you see, there is a one-to-many relationship between the two tables, linked with the MessageID field, which also enforces referential integrity with cascading updates and deletes. Note that the table names start with the prefix "Blog_". I always use a prefix for my SQL Server tables because they will be grouped together in Enterprise Manager when listed alphabetically. In addition, Web hosting plans usually offer only one SQL Server database that you have to share among all the Web modules you use. If you didn't use a table prefix, you may have a module that already uses a table named Messages, and solving such a name clashing problem is not immediate at deployment time.

Another important detail to remember is that no field should be nullable, even if the user leaves that field blank. Rather than permitting null values, you should set empty fields to the empty string to prevent unhandled exceptions. This isn't a problem if you use Web Forms for data input because the controls return an empty string if there is no content.

The Business Layer

In a typical database-centric application, you have data, business, and presentation layers. The data layer might consist of a set of stored procedures, while the business layer would be a set of classes (optionally compiled in their own separate assembly) that wrap the stored procedures to ensure data integrity, perform validation, and enforce other business rules. However, in this project I decided not to use stored procedures; I hardcoded the SQL queries and commands directly into the main assembly so it would be easier to use the blog with Access. I didn't separate the classes for the data and business layers either, but since there are just two tables and only simple business rules, I created a single class that takes care of validating the input and executing the proper SQL statements. All the code will be in the same assembly, together with the presentation layer, because I don't expect to update the business class separately from the program that uses it, and I don't expect to distribute the business class with other types of applications or among many clients. Thus, it's OK to compile it with the main ASP.NET application (called WebLogger). The business class is named Blog and is located under a namespace called "Business" so that it won't clash with the codebehind classes. Here's how the method that adds a new message is implemented:

Public Sub InsertMessage(ByVal title As String, ByVal message As String) Dim cmd As New OleDbCommand("INSERT INTO Blog_Messages (Title, _ Message) VALUES (?, ?)") cmd.Parameters.Add("Title", OleDbType.VarChar).Value = title cmd.Parameters.Add("Message", OleDbType.LongVarChar).Value = _ message.Replace(m_brChar, "<br>") ExecuteCommand(cmd) End Sub

The method takes in all the values it needs to add a new record. There is no need to pass in the message ID since it's auto-incremented in the database table and the AddedDate uses the current date as the default value. The method defines a SQL INSERT command and executes it with the ExecuteCommand helper method. Note that the value for the Message parameter is the message text passed in as input, whereas the new-line characters are replaced with the HTML <br> tag. The administrator is allowed to use HTML formatting in his or her posts, but since new lines are so frequent, they are automatically converted to HTML to spare all that typing. As you may have already guessed, the ExecuteCommand helper method simply executes the command object that it takes as input:

Private Sub ExecuteCommand(ByVal cmd As OleDbCommand) cmd.Connection = m_Connection Try m_Connection.Open() cmd.ExecuteNonQuery() Finally m_Connection.Close() End Try End Sub

The ExecuteCommand method sets the command's connection, which is created in the class's constructor method using a connection string retrieved from a custom key defined in the appSettings section of web.config. Then the connection is opened, the command is executed in a Try block, and the connection is closed in the corresponding Finally block. The connection is closed even if the ExecuteNonQuery method throws an exception. There is no Catch block, though, because I don't have to perform any business-specific operation, such as rolling back a transaction or logging an error. I just want to catch and handle the exception directly from the calling page, for example, to show a user-friendly error message. The other Insertxxx, Updatexxx, and Deletexxx methods work similarly, so I won't cover them all. It's valuable, though, to take a look at the InsertComment method because, in addition to adding a new record in the Blog_Comments table, it also sends a notification e-mail containing the comment text and some of the message it refers to the blog's owner.

With this feature in place, the blog owner doesn't have to continually load the blog and check for new e-mails. Of course, if the blog is very popular and gets a lot of comments, you might get too many e-mails. Therefore, the application's web.config should have a custom key that lets the administrator decide whether she wants to be notified by e-mail of any new comment. Yet another custom key is then required to store the destination e-mail address. These two settings are named Blog_SendNotifications and Blog_AdminEmail, respectively, and they are declared within the appSettings section of web.config (together with the custom key for the connection string) as follows:

<add key="Blog_SendNotifications" value="1" /> <add key="Blog_AdminEmail" value="mbellinaso@vb2themax.com" />

The Blog_SendNotifications value of 1 means that the notification feature is active and any other value or the absence of the key means that it is inactive.

After inserting the new comment and checking whether the comment notification is turned on you have to build the e-mail's body. In addition to the comment text, you also want to include the title, date, and time the parent message was posted so there is a clear connection between the original message and the comments. Retrieving the data of the parent message is performed by the code in Figure 2, with a command that retrieves the proper fields from the single message record in question.

Figure 2 Retrieving Parent Message Data

Dim sendNotifications As String = ConfigurationSettings.AppSettings( _ "Blog_SendNotifications") If Not sendNotifications Is Nothing AndAlso sendNotifications = "1" Then Dim msgTitle, msgDate As String ' read a few details of the parent message cmd.CommandText = "SELECT Title, AddedDate FROM Blog_Messages" & _ "WHERE MessageID = " & messageID m_Connection.Open() Dim reader As OleDbDataReader = cmd.ExecuteReader( _ CommandBehavior.CloseConnection) If reader.Read Then msgTitle = reader("Title") msgDate = CType(reader("AddedDate"), Date).ToString("hh:mm tt" & _ "on dddd, MMMM dd") reader.Close() End If •••

Once you have all the required data, you can build and send the e-mail message. The e-mail's text is built through a call to the String.Format method, which takes in a template string with numeric placeholders (of the form {n}) and the values that replace the various placeholders in the template:

' build the msg's content Dim msg As String = String.Format( _ "{0} (email: {1}) has just posted a comment the message ""{2}"" " & _ "of your BLOG that you posted at {3}. Here's the comment:{4}{4}{5}", _ author, email, msgTitle, msgDate, Environment.NewLine, comment) ' send the mail System.Web.Mail.SmtpMail.Send("WebLogger", _ ConfigurationSettings.AppSettings("Blog_AdminEmail"), _ "New comment notification", msg) End If

The most important element of the business class is the GetData method, which retrieves the messages posted in the specified interval, along with their respective comments. Here you have to choose whether you want to use a DataReader or a DataAdapter plus a DataSet to retrieve and read the data. The DataReader is often the best option in Web applications, especially when you don't need a locally editable and cacheable copy of the data. In this particular case, however, I chose the DataSet because it allows you to store multiple tables and create parent-child relationships between them. That makes it easy to retrieve all the parent and child records in a single shot and navigate from parent records to their child data without executing separate queries, as would be required if I used the DataReader approach. This method is simpler for the programmer and saves database resources and network traffic because fewer SQL statements are sent to the database.

The ability to create relationships between tables is another cool feature because you can add calculated columns to the tables which evaluate expressions with references to other tables in the relationship. I created a relationship between the Blog_Messages and Blog_Comments tables against the MessageID field, just as I did for the physical database in Figure 1. I wanted the DataTable with the data from Blog_Messages to have a calculated column that returns the number of child comments for that message record. If I used a DataReader, this would require separate queries or at least a subquery (something that is not possible with Access). With a DataSet, this is performed with the disconnected copy of the data without asking the database to count the child records.

Let's see how the method works. It takes two dates as input, uses a DataAdapter to retrieve the messages posted in that interval, and then stores them in a DataSet table named Messages, like so:

Public Function GetData(ByVal fromDate As Date, ByVal toDate As Date) _ As DataSet Dim ds As New DataSet() interval Dim da As New OleDbDataAdapter("SELECT * FROM Blog_Messages WHERE" & _ "AddedDate BETWEEN ? AND ?" & _ "ORDER BY AddedDate DESC", m_Connection) da.SelectCommand.Parameters.Add("FromDate", OleDbType.Date).Value = _ fromDate da.SelectCommand.Parameters.Add("ToDate", OleDbType.Date).Value = _ toDate.AddDays(1) m_Connection.Open() da.Fill(ds, "Messages") •••

Note that you must add one day to the end date, so that the messages posted the day of the passed-in end date are included in the resultset. If you want to get only the comments of the returned messages, then in the SELECT statement you use an IN filter which contains a comma-delimited list of all the message IDs of the records returned by the query I just showed (see Figure 3).

Figure 3 Filtering Comments

If ds.Tables("Messages").Rows.Count > 0 Then Dim inFilter As String = "" Dim dr As DataRow For Each dr In ds.Tables("Messages").Rows If inFilter = "" Then inFilter = dr("MessageID") Else inFilter &= ", " & dr("MessageID") End If Next ' build the SELECT for the Comments table da.SelectCommand.CommandText = "SELECT * FROM Blog_Comments" & _ "WHERE MessageID IN (" & _ "inFilter & ") ORDER BY AddedDate DESC" Else da.SelectCommand.CommandText = "SELECT * FROM Blog_Comments" & _ "WHERE MessageID = -1" End If da.Fill(ds, "Comments") m_Connection.Close()

Then you create the relationship I just described between the two tables, like this:

ds.Relations.Add(New DataRelation("MsgComments", _ ds.Tables("Messages").Columns("MessageID"), _ ds.Tables("Comments").Columns("MessageID")))

Next you add a calculated column to the Messages table that returns the number of child comments:

ds.Tables("Messages").Columns.Add("CommentsCount", _ GetType(Integer), "Count(Child(MsgComments).CommentID)")

The function Child(MsgComments) returns all the child data rows of the specified relationship, and the Count function works the same way it does in SQL.

One last detail to note is that before returning the filled DataSet and terminating the function, if the Comments table is empty, this calculated column will evaluate the expression as NULL, not 0, which will cause problems later when you show this value in the ASP.NET page or use it in other expressions. To solve the problem, you can add a mock comment that doesn't refer to any message:

If ds.Tables("Comments").Rows.Count = 0 Then Dim dr As DataRow = ds.Tables("Comments").NewRow() dr("CommentID") = -1 dr("Author") = "none" dr("Email") = "none" dr("Comment") = "none" dr("AddedDate") = Date.Today ds.Tables("Comments").Rows.Add(dr) dr.AcceptChanges() End If Return ds End Function

This way, the expression will evaluate to 0 if a message does not have any child comments.

Showing Messages and Child Comments

Now that the business layer is complete, the next step is to write the presentation layer where you can best appreciate the power of ASP.NET. Most of the work is performed on a single page, Default.aspx, which will render the messages and comments and allow the authenticated administrator to moderate the comments and edit her own messages (see the bottom pane of Figure 4).

Figure 4 The Blog in Administrator Mode

Figure 4** The Blog in Administrator Mode **

Default.aspx shows a list of messages grouped by day; each message can have zero or more comments. Initially, the comments are hidden, but when the user clicks on the View link, an inner list of comments is dynamically expanded or collapsed. Multiple messages can have their lists of comments expanded at the same time, like a TreeView control with a single level of sub-nodes. Developing this page presents some challenges including how to sort the messages by day and group them in separate HTML tables, and how to show or hide the inner lists of child records.

Let's start with the first issue. Assume that you want to show five messages on the page, the first three of which were posted the same day and the other two on another day. Also assume that you want to keep each message initially wrapped in its own HTML table. If you want to group the first three and the remaining two messages in two separate tables, you should remove the table's opening and closing tags from the messages in the middle of the table (the messages that are not the first or the last of that day), remove the closing tag from the first message's table, and the opening tag from the last message's table. Because I want to use a templated data-bound control to render this view, and since I must have full control over all the produced HTML, I chose to use the Repeater control. The Repeater control has no predefined output or layout beyond what you specify in its templates. The code in Figure 5 is a partial definition of the Repeater's template.

Figure 5 Repeater Template

<asp:repeater id="Blog" Runat="server"> <HeaderTemplate><table width="100%" border="1"><tr><td> </HeaderTemplate> <ItemTemplate> <asp:Literal Runat="server" ID="DayBox"> </td></tr></table>

The HeaderTemplate includes the table's opening tags, and the FooterTemplate closes the table. The tags that open and close the various tables that group the messages by date are defined in a Literal control within the ItemTemplate section. By toggling this Literal control's visibility, you decide whether to output those tags, and thus when to close the current table and open a new one. The question is when to hide the Literal so that the current table is kept open and the messages grouped and when to show it so that the table is closed and a new table is opened.

The answer is simple: the messages are grouped together while the date of the message being processed is the same as that of the previous message. When the date changes, the Literal control is shown and the new group is started. The other question is when and where the control's content should be visible. You have to decide this while the data item (the blog's message record) is being processed and bound to the Repeater's template in the Repeater's ItemDataBound event. Here you can read all the values from the current data item and compare them with the previous message's data, which has been stored in a static variable so that the value is preserved between method calls. You get a reference to the template's Literal control and can set its visibility accordingly. Here's the code:

Private Sub Blog_ItemDataBound(...) Handles Blog.ItemDataBound Static prevDayDate As Date If e.Item.ItemType <> ListItemType.Item AndAlso _ e.Item.ItemType <> ListItemType.AlternatingItem Then Return Dim dayDate As Date = e.Item.DataItem("AddedDate") Dim isNewDay As Boolean = (dayDate.ToShortDateString() <> _ prevDayDate.ToShortDateString()) prevDayDate = dayDate CType(e.Item.FindControl("DayTitle"), Panel).Visible = isNewDay CType(e.Item.FindControl("DayBox"), Literal).Visible = ( _ isNewDay AndAlso e.Item.ItemIndex > 0) End Sub

As you can see, I set the Visible property for a Panel control (also declared in the same template) that displays the date for the day of the current table. This panel is shown if the date of the current message differs from the date of the previous message, or it shows the default date if this is the first message being bound. The Literal's visibility has one more constraint for being set to True: the message being bound must not be the first one because in that case the day's table is opened with the tags declared in the Repeater's HeaderTemplate section. To dynamically collapse and expand the list of comments without a postback to the server and a page reprocessing, place the comments within a <DIV> tag whose display style can be set to "none" or to an empty string to hide or show it, respectively. The DIV is declared as follows, assigning it an ID based on the data-item being bound:

<div style="display:'none'; margin-left:2.0em; margin-top:.8em; " ID='<%# "div" & Container.DataItem("MessageID") %>'> <!-- put here the Comments DataList... --> </div>

I assigned the ID this way so that it is unique for each DIV (remember that there is one for each message). To expand or collapse the DIV, I used a hyperlink that calls custom JavaScript code that takes input from the DIV's ID:

<asp:HyperLink Runat="server" Visible='<%# Container.DataItem("CommentsCount") > 0 %>' NavigateUrl='<%# "javascript:ToggleDivState(div" & _ Container.DataItem("MessageID") & ");" %>'> View </asp:HyperLink>

Also note that the Visible property is bound to an expression that returns True only if the message has comments to show (if the value in its calculated CommentsCount column is greater than 0). The ToggleDivState just inverts the value of the DIV's display style to make it alternately visible or hidden:

function ToggleDivState(ctrl) { div = eval(ctrl); if (div.style.display == "none") div.style.display = ""; else div.style.display = "none"; }

Now let's look at the comments feature. A DataList will do the job this time because its table layout is exactly what I want. Typically, the DataSource property of a template control, or any other data-bound list control, is assigned programmatically in the codebehind (or in a server-side script section). In this case, however, I don't have a direct reference to the DataList because it is created dynamically by the parent Repeater. Like most properties, though, it can have a data binding expression that sets its value at run time. If you were using the DataReader approach to retrieve the data, the DataSource property could be bound to a custom method that took in the message's ID and returned the child comments in a DataTable, a DataReader, or any other data type that implements the IEnumerable interface. In this case it is not necessary because all the data you need has been already stored in the same DataSet that contains the messages. Since you created a relationship between the table with the message and the table with the comments, you can easily retrieve an array with the child comments using the GetChildRows method of the current data item's DataRow. The expression is declared like so:

<asp:DataList Runat="server" DataSource= '<%# Container.DataItem.Row.GetChildRows("MsgComments") %>'>

Complete the DataList's ItemTemplate with binding expressions to show the author name and e-mail address, the message text, and its date, and the code that outputs the blog's content is complete. Figure 6 shows the finished code for the output module.

Figure 6 Repeater and Nested Collapsible Comments

<asp:repeater id="Blog" Runat="server"> <HeaderTemplate> <table width="100%" border="1" cellpadding="0" cellspacing="0"> <tr><td> </HeaderTemplate> <ItemTemplate> <asp:Literal Runat="server" ID="DayBox"> </td></tr></table>

<%# Container.DataItem("Title") %> @ <%# DataBinder.Eval(Container.DataItem, "AddedDate", "{0:hh:mm tt}") %>
<%# Container.DataItem("Message") %>
-- <%# Container.DataItem("CommentsCount") %> comments: - Post your own comment

Selecting an Interval and Loading the Blog

Thus far I've covered the definition of the page content in the ASPX file, but I haven't yet reviewed any code to actually load the blog's content. To let the user select an interval, I used the Calendar control with the SelectionMode property set to DayWeekMonth, so that the user can select a single day, a week, or the entire month. It is a good idea to provide textboxes for a user's desired start and end dates in case he wants to select the last two weeks or the last 45 days, for example. Figure 7 shows the new controls added to the page with an entire week selected in the Calendar.

Figure 7 Intervals

Figure 7** Intervals **

When the page isn't loaded because of a postback, you must select a default interval, such as the last week. However, for frequently updated blogs, it may be better to load the data for fewer days or for the entire last month for rarely updated blogs. As with the comment notification feature, the best option is to leave the choice to the blog administrator using a custom key in the web.config file that would allow them to specify the number of days for the default interval. The following code shows how the custom key is read from the file, parsed into an Integer, and used to calculate an interval for the last n days, as well as how the interval is highlighted in the calendar:

Private Sub Page_Load(...) Handles MyBase.Load If Not IsPostBack Then Dim defPeriod As Integer = Integer.Parse( _ ConfigurationSettings.AppSettings("Blog_DefaultPeriod")) Dim fromDate = Date.Today.Subtract(New TimeSpan(defPeriod -_ 1,0,0,0)) BlogCalendar.SelectedDates.SelectRange(fromDate, Date.Today) BindData() End If End Sub

The start date is calculated by subtracting n-1 days from today's date. The call to BindData loads the data for the selected interval and binds it to the Repeater and its inner controls. This method calls the GetData method of the previously developed Blog business class and passes in the start and end dates of the calendar's selected date, which are read from its SelectedDates collection:

Private Sub BindData() Dim ds As DataSet = m_BlogManager.GetData( _ BlogCalendar.SelectedDates(0), _ BlogCalendar.SelectedDates(BlogCalendar.SelectedDates.Count - 1)) Blog.DataSource = ds.Tables("Messages").DefaultView Blog.DataBind() End Sub

If a single day is selected, the SelectedDates will have only one item and the start and end dates will be the same. When the user clicks the calendar, the page is posted back, the new interval is automatically selected, and you handle the calendar's SelectionChanged event to call the BindData again for the new interval. Finally, you must handle the Load button's Click event to select the specified custom interval in the calendar and load the blog's data. In this event procedure I parse the content of the two input controls and get two dates. Although I will eventually add validators to ensure that the date format is correct before the form is submitted, the parsing could throw an exception if the date format is not valid. In that case, I take today's date (see Figure 8).

Figure 8 Loading the Dates

Private Sub LoadBlog_Click(...) Handles LoadBlog.Click Dim fromDate, toDate As Date Try fromDate = Date.Parse(IntervalFrom.Text) Catch fromDate = Date.Today End Try ' do the same for the toDate... ' if toDate is before fromDate, set toDate = fromDate If toDate < fromDate Then toDate = fromDate BlogCalendar.SelectedDates.SelectRange(fromDate, toDate) BlogCalendar.VisibleDate = toDate BindData() End Sub

Note the use of the calendar's VisibleDate to ensure that the end date is visible in the calendar. This is necessary because if the user selects a period two months in the past, the selection would not be visible in the calendar. Instead, the calendar would show the current month, which wouldn't be very clear.

Popup Calendar

As it stands now, if the user wants to select an interval different from a single day, week, or month, they must manually type in the start and end date in the two textboxes and refer to an external calendar. In addition, they might type the date in an invalid format. For these reasons, it is a good practice to offer a popup calendar. When a date is clicked, the calendar should close and the date should appear in the main window's textbox control. This feature can be easily reproduced in ASP.NET using the Calendar control and a bit of client-side JavaScript.

First let's take care of the parent window's ASPX code. I added an image link that opens the popup window by calling the following JavaScript function:

<a href="https://javascript:PopupPicker('IntervalFrom', 200, 200);" > <img src="images/calendar.gif" border="0" > </a>

The JavaScript procedure expects to receive the name of the textbox control that will be filled with the selected date, along with the width and height of the popup calendar window that will be opened. Here's the JavaScript code that goes in the <script> section already defined at the top of the page:

function PopupPicker(ctl,w,h) { var PopupWindow=null; settings='width='+ w + ',height='+ h; PopupWindow=window.open('DatePicker.aspx?Ctl=' + ctl,'DatePicker',settings); PopupWindow.focus(); }

This procedure uses window.open to open the popup window with a specified, unresizable dimension, with no scrollbar, menu, toolbar, or status bar. The first parameter is the URL of the page to load into the new window, and in the code I just showed it loads a DatePicker.aspx page with a ctl parameter in the query string, whose value is passed in as input to the PopupPicker procedure.

Now that I'm finished in the main page, I have to write the DatePicker.aspx page that renders the calendar. This page has a Calendar control with its Width and Height properties set to 100 percent, so that it covers the whole page. The other important thing to note in the ASPX file is the client-side JavaScript procedure that takes a string as input and uses it as the value for the parent form's input control and whose name is passed on the query string. Finally, it closes the popup form itself. The JavaScript code is in Figure 9.

Figure 9 Setting the Date

<script language="Javascript"> function SetDate(dateValue) { // retrieve from the querystring the value of the ctl param, // that is, the name of the input control on the parent form // that the user wants to set with the clicked date ctl = window.location.search.substr(5); // set the value of that control with the passed date thisForm = window.opener.document.forms[0].elements[ctl].value = dateValue; // close this popup self.close(); } </script>

When the user clicks a link in the Calendar control, instead of the normal processing that submits the form and selects the clicked day, I want the custom JavaScript procedure to be invoked. By default, all the links rendered by the Calendar control generate a postback to the server. What I want instead is for them to point to the custom SetDate procedure. Changing the default output for the table cells that contain the day links is pretty easy thanks to the Calendar's DayRender event, which is raised every time a day is rendered and which provides a reference to the table cell being created. The following code snippet replaces the default cell's content with my own hyperlink control, which has the same text but points to the JavaScript procedure:

Private Sub DatePicker_DayRender(...) Handles DatePicker.DayRender Dim hl As New HyperLink() hl.Text = CType(e.Cell.Controls(0), LiteralControl).Text hl.NavigateUrl = "javascript:SetDate('" & _ e.Day.Date.ToShortDateString() & "');" e.Cell.Controls.Clear() e.Cell.Controls.Add(hl) End Sub

The value passed to the JavaScript procedure is the date of the clicked day in short format (typically mm/dd/yy). This value will be used for the input control on the parent form. Figure 10 shows the resulting popup window.

Figure 10 Popup Calendar

Figure 10** Popup Calendar **

As you can see, the ASP.NET server controls can be highly customized. Another time when you may want to use the DayRender event this way is when you need to redirect to another page with the date passed in the query string, instead of redirecting to the second page from the server after a postback with the date. To do this, just replace the line where you set the hyperlink's NavigateUrl property with something similar to the following:

hl.NavigateUrl = "SecondPage.aspx?Date=" & e.Day.Date.ToShortDateString()

Posting Comments

In Figure 4 you saw that below each message there is a "Post your own comment" link. When the user clicks it, he is presented with a box with input controls to post a comment. You can guess how this works because I used the same technique to build the collapsible lists of comments. The Comments box with its input controls is declared within a DIV whose display style is initially set to "none" so that it's not visible. When the link is clicked, the style is changed, and the page is scrolled to the bottom to make it visible. I define a single comment box at the bottom of the page, not one for each message, to avoid sending unnecessary HTML code that would slow down the page. Figure 11 shows the comment box and the required input controls.

Figure 11 Posting a Comment

Figure 11** Posting a Comment **

How do you specify the message to which the comment is being posted? A good solution is to store the parent message's ID in a hidden ASP.NET textbox when the "Post your own comment" link is clicked. Later, when the Post button is pressed, this value can be retrieved from the codebehind. Note that you can't use the Visible property to make the control hidden, because when Visible is set to False the control is hidden and the HTML code isn't sent to the client at all. You must use the same display style used for the DIV. The DIV and the textbox are declared as follows:

<div id="CommentBox" style="DISPLAY: none"> <a name="CommentBoxAnchor"></a> <asp:textbox id="ParentMessageID" style="DISPLAY: none" runat="server" />

Notice that I also used an anchor which will be used to ensure that the comment box is actually visible if the page is long and the message the user wants to comment on is at the top of the page. The link is declared like this:

<a href='<%# "javascript:ShowCommentBox(" & Container.DataItem("MessageID") & ");" %>'>Post your own comment</a>

The ShowCommentBox JavaScript routine takes in the ID of the message to comment on and uses it as the value for the hidden textbox control just declared:

function ShowCommentBox(msgID) { document.forms[0].ParentMessageID.value = msgID; ShowCommentBox2(); }

The code that actually makes the comment box visible and scrolls down the page is in a separate routine (the ShowCommentBox2 procedure). I will call this procedure again when I want to show the comment box without setting the value attribute of the hidden textbox control:

function ShowCommentBox2() { CommentBox.style.display = ""; window.location.href = '#CommentBoxAnchor'; }

All I have left to do is to handle the click on the Post button to call the InsertComment of the Business.Blog instance and rebind the updated data to the Repeater:

Private Sub PostComment_Click(...) Handles PostComment.Click m_BlogManager.InsertComment(Integer.Parse(ParentMessageID.Text), _ Author.Text, Email.Text, Comment.Text) BindData() ' reset the value of the input controls ParentMessageID.Text = "" ' reset the other visible textboxes... End Sub

Administering the Blog

The code for the user tasks is almost complete now. Users can read the messages for the selected interval and post comments. The blog owner, on the other hand, has to add, insert, and delete messages and comments by acting directly on the data store. To allow this access, the next major step is to develop a login page and modify the blog's main page so that when the administrator is logged in, the page shows additional controls for performing administrative operations. The login page is composed of textboxes for the user name and password, a checkbox for the "persistent login" option, and a submit button. Since you'll have only a single administrator and don't need role-based security, storing the credentials in the web.config file is enough. Figure 12 shows the codebehind class. If the specified credentials are valid, it authenticates the user and redirects to the Default.aspx page.

Figure 12 Codebehind Class for the Login Page

Imports System.Web.Security Public Class Login Inherits System.Web.UI.Page ' Web Form Designer Generated Code here... Protected UserName As System.Web.UI.WebControls.TextBox Protected Password As System.Web.UI.WebControls.TextBox Protected Persistent As System.Web.UI.WebControls.CheckBox Protected WithEvents LoginUser As System.Web.UI.WebControls.Button Protected InvalidLogin As System.Web.UI.WebControls.Label Private Sub Page_Load(ByVal sender As System.Object, ByVal e As _ System.EventArgs) Handles MyBase.Load If Not Page.IsPostBack Then ' if the querystring contains a "Action=logout" param, logout ' and delete the cookie If Request.Params("Action") = "logout" Then FormsAuthentication.SignOut() End If End If End Sub Protected Sub LoginUser_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles LoginUser.Click ' check username and password If FormsAuthentication.Authenticate(UserName.Text, Password.Text) Then ' if ok, save the cookie FormsAuthentication.SetAuthCookie(UserName.Text, _ Persistent.Checked) ' redirect to Default.aspx Response.Redirect("Default.aspx", True) Else ' if wrong credentials, show the error message InvalidLogin.Visible = True End If End Sub End Class

In the Default.aspx page, I'll add the editing controls that will be visible only if the user is authenticated. At the top of the page I declare a panel with a "logout" link that points to Login.aspx with the "action=logout" parameter in the query string, and a table with the textboxes to specify the message's title and content. This panel is shown or hidden in Page_Load, as follows:

MessageBox.Visible = User.Identity.IsAuthenticated

When the administrator fills the textboxes and clicks the Post button, the server-side Click event is raised, and in its event handler I use the InsertMessage function to add the new message to the database and call BindData to load it in the Repeater.

Now it's time to add the editing functionality. You can do this by adding a LinkButton control to the Repeater's ItemTemplate:

<asp:LinkButton CausesValidation="False" runat="server" Text="Edit" CommandName="Edit" CommandArgument='<%# Container.DataItem("MessageID") %>' Visible='<%# User.Identity.IsAuthenticated %>'/>

The link is visible only if the user is authenticated (it works as it did for the MessageBox panel, but here you show or hide it by means of a data binding expression in the ASPX file). The CommandArgument property contains the ID of the message to edit, but you also must specify the CommandName as "Edit" because you have another LinkButton for deleting the message and you want to make sure that it's clear which of the two buttons was clicked. The code for the Repeater's ItemCommand event handler is shown in Figure 13.

Figure 13 Handling Blog_ItemCommand

Private Sub Blog_ItemCommand(...) Handles Blog.ItemCommand Dim msgID = Integer.Parse(e.CommandArgument) If e.CommandName = "Edit" Then Dim msgTitle, msgText As String m_BlogManager.GetMessageData(msgID, msgTitle, msgText) MessageID.Text = msgID Title.Text = msgTitle Message.Text = msgText Else m_BlogManager.DeleteMessage(msgID) BindData() End If End Sub

The code first retrieves the message's ID passed with the clicked button's CommandArgument property. Then the administrator decides whether to delete or edit the message according to the button's CommandName. When it equals "Edit," the current data for the specified message is retrieved and used to fill the MessageBox's textboxes so that the administrator sees the current text and can edit it. When the Post button is clicked, if the MessageID textbox is empty, it means the administrator is posting a new message; otherwise the textbox contains the ID of the message being edited. Here's part of the Click event handler:

Private Sub PostMessage_Click(...) Handles PostMessage.Click If Not User.Identity.IsAuthenticated Then _ Response.Redirect("Login.aspx", True) If MessageID.Text.Trim().Length > 0 Then m_BlogManager.UpdateMessage(Integer.Parse(MessageID.Text), _ Title.Text, Message.Text) Else m_BlogManager.InsertMessage(Title.Text, Message.Text) End If ' reset the textboxes to an empty string, and call BindData... End If End Sub

The Add New and Edit functionalities are now complete (Figure 4 shows how the page looks in administrator mode). There is one more thing that's worth adding to the Delete functionality. As it stands now, if the administrator mistakenly clicks the Delete link, the message is deleted immediately since a confirmation was not requested. Adding a confirmation popup dialog is simple—it just needs some JavaScript in response to the hyperlink's onClick event. This is done by adding an entry in the control's Attributes collection when the link is created—that is, when the Repeater's item is created. I just need to handle the Repeater's ItemCreated event for both the odd and even items, get a reference to the Delete LinkButton, and add the JavaScript confirmation popup:

Private Sub Blog_ItemCreated(...) Handles Blog.ItemCreated If e.Item.ItemType <> ListItemType.AlternatingItem AndAlso _ e.Item.ItemType <> ListItemType.Item Then Exit Sub Dim lnkDelete As LinkButton = CType( _ e.Item.FindControl("DeleteMessage"), LinkButton) lnkDelete.Attributes.Add("onclick", _ "return confirm('Are you sure you want to delete this" & _ "message?');") End Sub

If the administrator clicks Cancel, the JavaScript returns false, the page is not submitted, and the message is not deleted.

The editing and deleting of comments are implemented in the same way, so I won't explain everything in detail again. However, you can find the complete implementation in the code download for this article. There are just a couple of details worth explaining here. Every time I had to handle a Repeater's event, I used the Visual Basic® .NET Handle keyword, which enables you to associate a method with the event of a control instance declared with WithEvents. The same cannot be done with the inner DataList for the comments because it's dynamically created at run time and there isn't a WithEvents control variable for it. You can specify the event handlers directly in the control's declaration though, as shown here:

<asp:DataList Runat="server" OnDeleteCommand="Comments_DeleteCommand" OnItemCreated="Comments_ItemCreated" OnEditCommand="Comments_EditCommand" ...>

The other small detail is that when the administrator clicks the Edit link for a comment, the comment box must be shown and the page scrolled to the bottom to ensure it is visible. I did this previously for the "Post your own comment" link, but in that case the JavaScript routine that does this is directly associated with the link, and there is no round-trip to the server. Here the page is first posted back to pre-fill the edit textboxes with the comment, and the JavaScript routine is called when the page is resent to the client browser. To do this, I send some JavaScript to the client that just calls the ShowCommentBox2 routine written before, like this:

Sub Comments_EditCommand(...) ' fill the textboxes with the current data for the clicked comment ••• Dim script As String = _ "<script language=""JavaScript"">ShowCommentBox2();</script>" Me.RegisterStartupScript("ShowEditCommentBox", script) End Sub

The RegisterStartupScript emits the specified block of JavaScript just before the page's server-side <form> tag is closed, ensuring that the CommentBox has already been created (otherwise you could get a wrong reference error when the CommentBox container is not found).

Validation for Multiple Virtual Forms

Where there are textboxes or other input controls, it is often a good idea to add validator controls to ensure that a value is supplied and that it's in the proper format and range. In this application, you must perform different validations according to the action that the user wants to take. If the user presses the Post button to submit a comment, you must ensure that he has provided his name and the comment text. If the Load Blog button is clicked, you must check that the format of the start and end dates is valid. I haven't added the validators yet because there is one more problem that I need to resolve.

I have three "virtual forms" with input controls: the comment box, the new message box, and the interval selection box. In an ASP.NET page you can have only one server-side form. This means that all the input controls, validators, and submit buttons are in the same form. Once the user has correctly filled the textboxes for the interval and clicked the submit button, the textbox validators validate the input. The validators for the comment and message boxes will prevent the form from being posted back when their textboxes don't have a value or the value is not formed correctly.

To solve the problem, I replaced the standard ASP.NET buttons with a custom control developed by James M. Venglarik, II for his MSDN® Magazine article "Selectively Enable Form Validation When Using ASP.NET Web Controls". This control creates buttons that disable a specified list of validators using some client-side JavaScript, and thus makes it possible to have buttons that, before submitting the page, validate some input controls but not others. Once the control is referenced by the page, here's how the Load Blog button is declared:

<nfvc:NoFormValButton ID="LoadBlog" Runat=Server Text="Load" NoFormValList="RequireAuthor,RequireComment,ValidateEmailFormat" />

The NoFormValList property specifies a comma-separated list of validators to disable when it's clicked, which in this case are all the validators for the textboxes in the comment and message boxes.

Conclusion

The application built in this article is now fully functional. You can upload it to your own server and write your blog, or you can see it live at https://www.bytecommerce.com/blog. The DataSet's relationship capabilities and the ability to create nested DataLists and DataGrids make it very easy to render master-detail reports, which is really what you're doing in this blog application. The flexibility of the template controls means you can create almost any layout. You can group multiple data items in tables and customize the behavior of the default implementation, as you saw when adding the confirmation popup to the Delete buttons. The controls combined with a bit of client-side JavaScript, to script their behavior and glue them together, makes for a complete and feature-rich ASP.NET reporting application.

For related articles see:
ASP.NET: Selectively Enable Form Validation When Using ASP.NET Web Controls

For background information see:
ASP.NET Security: An Introductory Guide to Building and Deploying More Secure Sites with ASP.NET and IIS
ASP.NET Security: An Introductory Guide to Building and Deploying More Secure Sites with ASP.NET and IIS, Part 2
https://weblogs.asp.net

Marco Bellinaso is technical editor of VB-2-The-Max (https://www.vb2themax.com) and works as a software developer and trainer for Code Architects Srl, an Italian company that specializes in the .NET Framework. He is a coauthor of several books including ASP.NET Website Programming (C# and Visual Basic .NET editions, by Wrox Press, 2003).