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 **
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 **
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:
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:
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:
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:
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:
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
|