Solution

JavaScript EditorFreeware javascript editor     Javascript code


Main Page

Previous Page
Next Page

Solution

In this section we'll cover the implementation of key parts of this module, as described in the "Design" section. But you won't find complete source code printed here, as many similar classes were discussed in other chapters. See the code download to get the complete source code.

Implementing the Database

The most interesting stored procedure is tbh_Forums_InsertPost. This inserts a new record into the tbh_Posts table, and if the new post being inserted is approved it must also update the ReplyCount, LastPostBy, and LastPostDate fields of this post's parent post. Because there are multiple statements in this stored procedure, a transaction is used to ensure that they are both either committed successfully or rolled back:

ALTER PROCEDURE dbo.tbh_Forums_InsertPost
(
   @AddedDate        datetime,
   @AddedBy          nvarchar(256),
   @AddedByIP        nchar(15),
   @ForumID          int,
   @ParentPostID     int,
   @Title            nvarchar(256),
   @Body             ntext,
   @Approved         bit,
   @Closed           bit,
   @PostID           int OUTPUT
)
AS
SET NOCOUNT ON

BEGIN TRANSACTION InsertPost

INSERT INTO tbh_Posts
   (AddedDate, AddedBy, AddedByIP, ForumID, ParentPostID, Title, Body, Approved,
      Closed, LastPostDate, LastPostBy)
   VALUES (@AddedDate, @AddedBy, @AddedByIP, @ForumID, @ParentPostID, @Title,
      @Body, @Approved, @Closed, @AddedDate, @AddedBy)

SET @PostID = scope_identity()

-- if the post is approved, update the parent post's
-- ReplyCount and LastReplyDate fields
IF @Approved = 1 AND @ParentPostID > 0
   BEGIN
   UPDATE tbh_Posts SET ReplyCount = ReplyCount + 1, LastPostDate = @AddedDate,
      LastPostBy = @AddedBy
      WHERE PostID = @ParentPostID
   END

IF @@ERROR > 0
   BEGIN
   RAISERROR(‘Insert of post failed', 16, 1)
   ROLLBACK TRANSACTION InsertPost
   RETURN 99
   END

COMMIT TRANSACTION InsertPost

If the post being inserted must be reviewed before being approved, its parent posts won't be modified because we don't want to count posts that aren't visible. When it gets approved later, the tbh_Forums_ApprovePost stored procedure will set this post's Approved field to 1, and then update its parent post's fields mentioned above. The ReplyCount field must also be incremented by one, but to update the parent post's LastPostBy and LastPostDate fields, the procedure needs the values of the AddedBy and AddedDate fields of the post being approved, so it executes a fast query to retrieve this information and stores it in local variables, and then makes the parent post's update using those values, as shown below:

ALTER PROCEDURE dbo.tbh_Forums_ApprovePost
(
 @PostID int
)
AS

BEGIN TRANSACTION ApprovePost

UPDATE tbh_Posts SET Approved = 1 WHERE PostID = @PostID

-- get the approved post's parent post and added date
DECLARE @ParentPostID   int
DECLARE @AddedDate      datetime
DECLARE @AddedBy        nvarchar(256)

SELECT @ParentPostID = ParentPostID, @AddedDate = AddedDate, @AddedBy = AddedBy
   FROM tbh_Posts
   WHERE PostID = @PostID

-- update the LastPostDate, LastPostBy and ReplyCount fields
-- of the approved post's parent post
IF @ParentPostID > 0
   BEGIN
   UPDATE tbh_Posts
      SET ReplyCount = ReplyCount + 1, LastPostDate = @AddedDate,
         LastPostBy = @AddedBy
      WHERE PostID = @ParentPostID
   END

IF @@ERROR > 0
   BEGIN
   RAISERROR(‘Approval of post failed', 16, 1)
   ROLLBACK TRANSACTION ApprovePost
   RETURN 99
   END

COMMIT TRANSACTION ApprovePost

Implementing the Data Access Layer

Most of the DAL methods are simply wrappers for stored procedures, so they won't be covered here. The GetThreads method is interesting: It returns the list of threads for a specified forum (a page of the results), and it is passed the page index and the page size. It also takes the sort expression used to order the threads. The method uses SQL Server 2005's new ROW_NUMBER function to provide a unique auto-incrementing number to all rows in the table, sorted as specified, and then selects those rows with an index number between the lower and the upper bound of the specified page. The SQL code is very similar to the tbh_Articles_GetArticlesByCategory stored procedure developed for the articles module in Chapter 5. The only difference (other than having the SQL code in a C# class instead of inside a stored procedure) is the fact that the sorting expression expected by the ORDER BY clause is dynamically added to the SQL string, as specified by an input parameter. Here's the method, which is implemented in the MB.TheBeerHouse.DAL.SqlClient.SqlForumsProvider class:

public override List<PostDetails> GetThreads(
   int forumID, string sortExpression, int pageIndex, int pageSize)
{
   using (SqlConnection cn = new SqlConnection(this.ConnectionString))
   {
      sortExpression = EnsureValidSortExpression(sortExpression);
      int lowerBound = pageIndex * pageSize + 1;
      int upperBound = (pageIndex + 1) * pageSize;
      string sql = string.Format(@"
SELECT * FROM
(
   SELECT tbh_Posts.PostID, tbh_Posts.AddedDate, tbh_Posts.AddedBy,
   tbh_Posts.AddedByIP, tbh_Posts.ForumID, tbh_Posts.ParentPostID, tbh_Posts.Title,
   tbh_Posts.Approved, tbh_Posts.Closed, tbh_Posts.ViewCount, tbh_Posts.ReplyCount,
   tbh_Posts.LastPostDate, tbh_Posts.LastPostBy, tbh_Forums.Title AS ForumTitle,
   ROW_NUMBER() OVER (ORDER BY {0}) AS RowNum
   FROM tbh_Posts INNER JOIN tbh_Forums ON tbh_Posts.ForumID = tbh_Forums.ForumID
   WHERE tbh_Posts.ForumID = {1} AND ParentPostID = 0 AND Approved = 1
) ForumThreads
WHERE ForumThreads.RowNum BETWEEN {2} AND {3} ORDER BY RowNum ASC",
         sortExpression, forumID, lowerBound, upperBound);

      SqlCommand cmd = new SqlCommand(sql, cn);
      cn.Open();
      return GetPostCollectionFromReader(ExecuteReader(cmd), false);
   }
}

At the beginning of the preceding method's body, the sortExpression string is passed to a method named EnsureValidSortExpression (shown below), and its result is assigned back to the sortExpression variable. EnsureValidSortExpression, as its name clearly suggests, ensures that the input string is a valid sort expression that references a field in the tbh_Posts table, and not some illegitimate SQL substring used to perform a SQL injection attack. You should always do this kind of validation when building a dynamic SQL query by concatenating multiple strings coming from different sources (this is not necessary when using parameters, but unfortunately the ORDER BY clause doesn't support the use of parameters). Following is the method's implementation:

protected virtual string EnsureValidSortExpression(string sortExpression)
{
   if (string.IsNullOrEmpty(sortExpression))
      return "LastPostDate DESC";

   string sortExpr = sortExpression.ToLower();
   if (!sortExpr.Equals("lastpostdate") && !sortExpr.Equals("lastpostdate asc") &&
      !sortExpr.Equals("lastpostdate desc") && !sortExpr.Equals("viewcount") &&
      !sortExpr.Equals("viewcount asc") && !sortExpr.Equals("viewcount desc") &&
      !sortExpr.Equals("replycount") && !sortExpr.Equals("replycount asc") &&
      !sortExpr.Equals("replycount desc") && !sortExpr.Equals("addeddate") &&

      !sortExpr.Equals("addeddate asc") && !sortExpr.Equals("addeddate desc") &&
      !sortExpr.Equals("addedby") && !sortExpr.Equals("addedby asc") &&
      !sortExpr.Equals("addedby desc") && !sortExpr.Equals("title") &&
      !sortExpr.Equals("title asc") && !sortExpr.Equals("title desc") &&
      !sortExpr.Equals("lastpostby") && !sortExpr.Equals("lastpostby asc") &&
      !sortExpr.Equals("lastpostby desc"))
   {
      return "LastPostDate DESC";
   }
   else
   {
      if (sortExpr.StartsWith("title"))
         sortExpr = sortExpr.Replace("title", "tbh_posts.title");
      if (!sortExpr.StartsWith("lastpostdate"))
         sortExpr += ", LastPostDate DESC";
      return sortExpr;
   }
}

As you see, if the sortExpression is null or an empty string, or if it doesn't reference a valid field, the method returns "LastPostDate DESC" as the default, which will sort the threads from the newest to the oldest.

Implementing the Business Logic Layer

As for the DAL, the BLL of this module is similar to those used in other chapters — Chapter 5 in particular. It employs the same patterns for retrieving and managing data by delegating the work to the respective DAL methods, for caching and purging data, for loading expensive and heavy data (such as the post's body) only when required, and so on. Here's the GetThreads method of the MB.TheBeerHouse.BLL.Forums.Post class:

public static List<Post> GetThreads(int forumID)
{
   return GetThreads(forumID, "", 0, BizObject.MAXROWS);
}

public static List<Post> GetThreads(
   int forumID, string sortExpression, int startRowIndex, int maximumRows)
{
   if (forumID < 1)
      return GetThreads(sortExpression, startRowIndex, maximumRows);

   List<Post> posts = null;
   string key = "Forums_Threads_" + forumID.ToString() + "_" + sortExpression +
      "_" + startRowIndex.ToString() + "_" + maximumRows.ToString();

   if (BaseForum.Settings.EnableCaching && BizObject.Cache[key] != null)
   {
      posts = (List<Post>)BizObject.Cache[key];
   }
   else
   {

      List<PostDetails> recordset = SiteProvider.Forums.GetThreads(forumID,
         sortExpression, GetPageIndex(startRowIndex, maximumRows), maximumRows);
      posts = GetPostListFromPostDetailsList(recordset);
      BaseForum.CacheData(key, posts);
   }
   return posts;
}

This method has two overloads: The first takes the ID of the forum for which you want to retrieve the list of threads; and the other also takes a sort expression string, the index of the first row to retrieve, and the number of rows you want to retrieve in the page of results. The first overload simply calls the second one by passing default values for the last three parameters. If the forumID is less than 1, the call is forwarded to yet another overload of GetThreads (not shown here) that retrieves the threads without considering their parent forum.

Pagination of items is also supported by the page showing all the posts of a thread, which are retrieved by the Post.GetThreadByID method, which in turn calls the tbh_Forums_GetThreadByID stored procedure by means of the DAL. However, the stored procedure doesn't support pagination, and it returns all the records — this is done because a user will typically read all of the thread, or most of it, so it makes sense to retrieve, and cache, all posts in a single step. The BLL method adds some logic to return a sublist of Post objects, according to the startRowIndex and maximumRows input parameters. Here's how it works:

public static List<Post> GetThreadByID(int threadPostID)
{
   return GetThreadByID(threadPostID, 0, BizObject.MAXROWS);
}
public static List<Post> GetThreadByID(int threadPostID,
   int startRowIndex, int maximumRows)
{
   List<Post> posts = null;
   string key = "Forums_Thread_" + threadPostID.ToString();

   if (BaseForum.Settings.EnableCaching && BizObject.Cache[key] != null)
   {
      posts = (List<Post>)BizObject.Cache[key];
   }
   else
   {
      List<PostDetails> recordset = SiteProvider.Forums.GetThreadByID(
         threadPostID);
      posts = GetPostListFromPostDetailsList(recordset);
      BaseForum.CacheData(key, posts);
   }

   int count = (posts.Count < startRowIndex + maximumRows ?
      posts.Count - startRowIndex : maximumRows);
   Post[] array = new Post[count];
   posts.CopyTo(startRowIndex, array, 0, count);
   return new List<Post>(array); ;
}

Implementing the User Interface

Before you start coding the user interface pages, you should modify the web.config file to add the necessary profile properties to the <profile> section. The required properties are AvatarUrl and Signature, both of type string, and a Posts property, of type integer, used to store the number of posts submitted by the user. They are used by authenticated users, and are defined within a Forum group, as shown below:

<profile defaultProvider="TBH_ProfileProvider">
   <providers>...</providers>
   <properties>
      <add name="FirstName" type="String" />
      <add name="LastName" type="String" />
      <!-- ...other properties here... -->
      <group name="Forum">
         <add name="Posts" type="Int32" />
         <add name="AvatarUrl" type="String" />
         <add name="Signature" type="String" />
      </group>
      <group name="Address">...</group>
      <group name="Contacts">...</group>
      <group name="Preferences">...</group>
   </properties>
</profile>

You must also change the UserProfile.ascx user control accordingly, so that it sets the new AvatarUrl and Signature properties (but not the Posts property, because it can only be set programmatically). This modification just needs a few lines of markup code in the .ascx file, and a couple of lines of C# code to read and set the properties, so I won't show them here. Once this "background work" is done, you can start creating the pages.

Administering and Viewing Forums

The definition of a subforum is almost identical to article categories employed in Chapter 5, with the unique addition of the Moderated field. The DAL and BLL code is similar to that developed earlier, but the UI for adding, updating, deleting, and listing forums is also quite similar. Therefore, I won't cover those pages here, but Figure 8-5 shows how they should look. As usual, consult the code download for the complete code.

Image from book
Figure 8-5

The AddEditPost.aspx Page

This page has a simple interface, with a textbox for the new post's title, a FCKeditor instance to create the post's body with a limited set of HTML formatting, and a checkbox to indicate that you won't allow replies to the post (the checkbox is only visible when a user creates a new thread and is not replying to an existing thread, or the user is editing an existing post). Figure 8-6 shows how it is presented to the user who wants to create a new thread.

Image from book
Figure 8-6

The page's markup is as simple, having only a few controls, so it's not shown here. The page's code-behind class defines a few private properties that store information retrieved from the querystring or calculate it:

private int forumID = 0;
private int threadID = 0;
private int postID = 0;
private int quotePostID = 0;
private bool isNewThread = false;
private bool isNewReply = false;
private bool isEditingPost = false;

Not all variables are used in every function of the page. The following list defines whether these variables are used, and how they are set for each function of the page:

  • Creating a new thread:

    • forumID: set with the ForumID querystring parameter

    • threadID: not used

    • postID: not used

    • quotePostID: not used

    • isNewThread: set to true

    • isNewReply: set to false

    • isEditingPost: set to false

  • Posting a new reply to an existing thread:

    • forumID: set with the ForumID querystring parameter

    • threadID: set with the ThreadID querystring parameter, which is the ID of the target thread for the new reply

    • postID: not used

    • quotePostID: not used

    • isNewThread: set to false

    • isNewReply: set to true

    • isEditingPost: set to false

  • Quoting an existing post to be used as a base for a new reply to an existing thread:

    • forumID: set with the ForumID querystring parameter

    • threadID: set with the ThreadID querystring parameter

    • postID: not used

    • quotePostID: set with the QuotePostID querystring parameter, which is the ID of the post to quote

    • isNewThread: set to false

    • isNewReply: set to true

    • isEditingPost: set to false

  • Editing an existing post:

    • forumID: set with the ForumID querystring parameter

    • threadID: set with the ThreadID querystring parameter — necessary for linking back to the thread's page after submitting the change, or if the editor wants to cancel the editing and go back to the previous page

    • postID: set with the PostID querystring parameter, which is the ID of the post to edit

    • quotePostID: not used

    • isNewThread: set to false

    • isNewReply: set to false

    • isEditingPost: set to true

The variables are set in the Page's Load event handler, which also has code that loads the body of the post to edit or quote, sets the link to go back to the previous page, and checks whether the current user is allowed to perform the requested function. Here's the code:

protected void Page_Load(object sender, EventArgs e)
{
   // retrieve the querystring parameters
   forumID = int.Parse(this.Request.QueryString["ForumID"]);
   if (!string.IsNullOrEmpty(this.Request.QueryString["ThreadID"]))
   {
      threadID = int.Parse(this.Request.QueryString["ThreadID"]);
      if (!string.IsNullOrEmpty(this.Request.QueryString["QuotePostID"]))
      {
         quotePostID = int.Parse(this.Request.QueryString["QuotePostID"]);
      }
   }
   if (!string.IsNullOrEmpty(this.Request.QueryString["PostID"]))
   {
      postID = int.Parse(this.Request.QueryString["PostID"]);
   }

   isNewThread = (postID == 0 && threadID == 0);
   isEditingPost = (postID != 0);
   isNewReply = (!isNewThread && !isEditingPost);

   // show/hide controls, and load data according to the parameters above
   if (!this.IsPostBack)
   {
      bool isModerator = (this.User.IsInRole("Administrators") ||
         this.User.IsInRole("Editors") || this.User.IsInRole("Moderators"));

      lnkThreadList.NavigateUrl = string.Format(
        lnkThreadList.NavigateUrl, forumID);
      lnkThreadPage.NavigateUrl = string.Format(
        lnkThreadPage.NavigateUrl, threadID);
      txtBody.BasePath = this.BaseUrl + "FCKeditor/";
      chkClosed.Visible = isNewThread;

      if (isEditingPost)
      {
         // load the post to edit, and check that the current user has the
         // permission to do so
         Post post = Post.GetPostByID(postID);
         if (!isModerator &&
            !(this.User.Identity.IsAuthenticated &&
            this.User.Identity.Name.ToLower().Equals(post.AddedBy.ToLower())))
            this.RequestLogin();

         lblEditPost.Visible = true;
         btnSubmit.Text = "Update";
         txtTitle.Text = post.Title;
         txtBody.Value = post.Body;
         panTitle.Visible = isModerator;
      }
      else if (isNewReply)

      {
         // check whether the thread the user is adding a reply to is still open
         Post post = Post.GetPostByID(threadID);
         if (post.Closed)
            throw new ApplicationException(
               "The thread you tried to reply to has been closed.");

         lblNewReply.Visible = true;
         txtTitle.Text = "Re: "+ post.Title;
         lblNewReply.Text = string.Format(lblNewReply.Text, post.Title);
         // if the ID of a post to be quoted is passed on the querystring, load
         // that post and prefill the new reply's body with that post's body
         if (quotePostID > 0)
         {
            Post quotePost = Post.GetPostByID(quotePostID);
            txtBody.Value = string.Format(@"
<blockquote>
<hr noshade=""noshade"" size=""1"" />
<b>Originally posted by {0}</b><br /><br />
{1}
<hr noshade=""noshade"" size=""1"" />
</blockquote>", quotePost.AddedBy, quotePost.Body);
         }
      }
      else if (isNewThread)
      {
         lblNewThread.Visible = true;
         lnkThreadList.Visible = true;
         lnkThreadPage.Visible = false;
      }

   }
}

When the user clicks the Post button, the class fields discussed previously are used again to determine whether an InsertPost or an UpdatePost is required. When editing a post, a line is dynamically added at the end of the post's body to log the date and time of the update, and the editor's name. When the post is inserted, you must also check whether the target forum is moderated; and if it is, you can only pass true to the InsertPost's approved parameter if the current user is a power user (administrator, editor, or moderator). After inserting the post, you also increment the author's Posts profile property. Here's the full code for the submit button's OnClick event handler:

protected void btnSubmit_Click(object sender, EventArgs e)
{
    if (isEditingPost)
    {
       // when editing a post, a line containing the current Date/Time and the
       // name of the user making the edit is added to the post's body so that
       // the operation gets logged
       string body = txtBody.Value;
       body += string.Format("<p>-- {0}: post edited by {1}.</p>",
          DateTime.Now.ToString(), this.User.Identity.Name);
       // edit an existing post
       Post.UpdatePost(postID, txtTitle.Text, body);

      panInput.Visible = false;
      panFeedback.Visible = true;
   }
   else
   {
      // insert the new post
      Post.InsertPost(forumID, threadID,
         txtTitle.Text, txtBody.Value, chkClosed.Checked);
      panInput.Visible = false;
      // increment the user's post counter
      this.Profile.Forum.Posts += 1;
      // show the confirmation message or the message saying that approval is
      // required, according to the target forum's Moderated property
      Forum forum = Forum.GetForumByID(forumID);
      if (forum.Moderated)
      {
         if (!this.User.IsInRole("Administrators") &&
            !this.User.IsInRole("Editors") &&
            !this.User.IsInRole("Moderators"))
            panApprovalRequired.Visible = true;
         else
            panFeedback.Visible = true;
      }
      else
      {
         panFeedback.Visible = true;
      }
   }
}

The ManageUnapprovedPosts.aspx Page

This page enables power users to see the list of messages waiting for approval for moderated forums, and allows them to review their content and then either approve or delete them. The page is pretty simple, as there's just a GridView that shows the title and a few other fields of the posts, without support for pagination or sorting. A screenshot is shown in Figure 8-7.

Image from book
Figure 8-7

The code to manage this grid has a small peculiarity: When the editor clicks on the post's title to select the row and then load and review its full body, ideally we'd like to show the body along with the selected rows, instead of in a separate DetailsView, or even into a separate page. In more complex cases where you have to show a lot of details of the selected row, those are actually the best way to go, but in this case, we just have a single additional field to show when selecting the row, and I think that showing it in the row would make things easier and more intuitive for the editor. It would be great if the GridView's TemplateField column had a SelectedItemTemplate section, like the DataList control, but unfortunately it doesn't. You could use a DataList, but then you'd have to replicate the grid's tabular structure by hand. The trick that enables us to use a custom template for the selected row in a GridView is to enable the edit mode for that row, and then use its EditItemTemplate to define its user interface for that mode. When the user clicks the post's title, this will put the row in edit mode (the LinkButton's CommandName property will be set to "Edit"), and therefore the EditItemTemplate will be used. Of course, you are not forced to use input controls for the EditItemTemplate, and you can use other Label and read-only controls instead (exactly as you would do for a fictitious SelectedItemTemplate section, if it were supported). In practice, while the row will actually be in edit mode, it will look like it's in the selected mode. This is only possible when you don't need to support real editing for the row! The following code is an extract of the GridView's definition, i.e., the declaration of the TemplateField column described here:

<asp:TemplateField HeaderText="Title" HeaderStyle-HorizontalAlign="Left">
   <ItemTemplate>
      <asp:LinkButton ID="lnkTitle" runat="server"
         Text='<%# Eval("Title") %>' CommandName="Edit" /><br />
      <small>by <asp:Label ID="lblAddedBy" runat="server"
         Text='<%# Eval("AddedBy") %>'></asp:Label></small>
   </ItemTemplate>
   <EditItemTemplate>
      <asp:Label ID="lblTitle" runat="server" Font-Bold="true"
         Text='<%# Eval("Title") %>' /><br />
      <small>by <asp:Label ID="lblAddedBy" runat="server"
         Text='<%# Eval("AddedBy") %>'></asp:Label><br /><br />
      <div style="border-top: dashed 1px;border-right: dashed 1px;">
         <asp:Label runat="server" ID="lblBody" Text='<%# Eval("Body") %>' />
      </div></small>
   </EditItemTemplate>
</asp:TemplateField>

The BrowseThreads.aspx Page

This page takes a ForumID parameter on the querystring with the ID of the forum the user wants to browse, and fills a paginable GridView control with the thread list returned by the Post.GetThreads business method. Figure 8-8 shows a screenshot of this page.

Image from book
Figure 8-8

In addition to the GridView control with the threads, the page also features a DropDownList at the top of the page, which lists all available forums (retrieved by an ObjectDataSource that uses the Forum.GetForums business method) and allows users to quickly navigate to a forum by selecting one. The DropDownList onchange client-side (JavaScript) event redirects the user to the same BrowseThreads.aspx page, but with the newly selected forum's ID on the querystring:

<asp:DropDownList ID="ddlForums" runat="server" DataSourceID="objForums"
   DataTextField="Title" DataValueField="ID"
   onchange="javascript:document.location.href='BrowseThreads.aspx?ForumID=' +
this.value;" />
<asp:ObjectDataSource ID="objForums" runat="server" SelectMethod="GetForums"
   TypeName="MB.TheBeerHouse.BLL.Forums.Forum" />

The GridView is bound to another ObjectDataSource control, which specifies the methods to select and delete data. Because we want to support pagination, we use the GetThreadCount method to return the total thread count. To support sorting you must set SortParameterName to the name of the parameter that the SelectMethod (i.e., GetThreads) will use to receive the sort expression; in this case, as shown earlier, it's sortExpression. Here's the complete declaration of the ObjectDataSource:

<asp:ObjectDataSource ID="objThreads" runat="server"
   TypeName="MB.TheBeerHouse.BLL.Forums.Post"
   DeleteMethod="DeletePost" SelectMethod="GetThreads"
   SelectCountMethod="GetThreadCount"
   EnablePaging="true" SortParameterName="sortExpression">
   <DeleteParameters>
      <asp:Parameter Name="id" Type="Int32" />
   </DeleteParameters>
   <SelectParameters>
      <asp:QueryStringParameter Name="forumID"
         QueryStringField="ForumID" Type="Int32" />
   </SelectParameters>
</asp:ObjectDataSource>

The following GridView control has both the AllowPaging and AllowSorting properties set to true, and it defines the following columns:

  • A TemplateColumn that displays an image representing a folder, which is used to identify a discussion thread. A templated column is used in place of a simpler ImageColumn, because the image being shown varies according to the number of posts in the thread. If the post count reaches a certain value (specified in the configuration), it will be considered a hot thread, and a red icon will be used to highlight it.

  • A TemplateColumn defining a link to the ShowThread.aspx page on the first line, with the thread's title as the link's text, and the thread's author's name in smaller text on the second line. The link on the first line also includes the thread's ID on the querystring so that the page will load that specific thread's posts.

  • A TemplateColumn that shows the date of the thread's last post on the first line, and the name of the author who entered the thread's last post on the second line. The column's SortExpression is LastPostDate.

  • A BoundField column that shows the thread's ReplyCount, and has a header link that sorts threads on this column

  • A BoundField column that shows the thread's ViewCount, and has a header link that sorts threads on this column

  • A HyperLinkField column pointing to the MoveThread.aspx page, which takes the ID of the thread to move on the querystring. This column will not be shown if the current user is not a power user.

  • A ButtonField column to close the thread and stop replies to it. This column will not be shown if the current user is not a power user.

  • A ButtonField column to delete the thread with all its posts. This column will not be shown if the current user is not a power user.

Following is the complete markup code for the GridView:

<asp:GridView ID="gvwThreads" runat="server" AllowPaging="True"
   AutoGenerateColumns="False" DataSourceID="objThreads" PageSize="25"
   AllowSorting="True" DataKeyNames="ID" OnRowCommand="gvwThreads_RowCommand"
   OnRowCreated="gvwThreads_RowCreated">
   <Columns>
      <asp:TemplateField ItemStyle-Width="16px">
         <ItemTemplate>
            <asp:Image runat="server" ID="imgThread" ImageUrl="~/Images/Thread.gif"
               Visible='<%# (int)Eval("ReplyCount") <
                  Globals.Settings.Forums.HotThreadPosts %>'
               GenerateEmptyAlternateText="" />
            <asp:Image runat="server" ID="imgHotThread"
               ImageUrl="~/Images/ThreadHot.gif"
               Visible='<%# (int)Eval("ReplyCount") >=
                  Globals.Settings.Forums.HotThreadPosts %>'
               GenerateEmptyAlternateText="" />
         </ItemTemplate>
         <HeaderStyle HorizontalAlign="Left" />
      </asp:TemplateField>
      <asp:TemplateField HeaderText="Title">
         <ItemTemplate>
            <asp:HyperLink ID="lnkTitle" runat="server" Text='<%# Eval("Title") %>'
               NavigateUrl='<%# "ShowThread.aspx?ID="+ Eval("ID") %>' /><br />
            <small>by <asp:Label ID="lblAddedBy" runat="server"
               Text='<%# Eval("AddedBy") %>'></asp:Label></small>
         </ItemTemplate>
         <HeaderStyle HorizontalAlign="Left" />
      </asp:TemplateField>
      <asp:TemplateField HeaderText="Last Post" SortExpression="LastPostDate">
         <ItemTemplate>
            <small><asp:Label ID="lblLastPostDate" runat="server"
               Text='<%# Eval("LastPostDate", "{0:g}") %>'></asp:Label><br />
            by <asp:Label ID="lblLastPostBy" runat="server"
               Text='<%# Eval("LastPostBy") %>'></asp:Label></small>
         </ItemTemplate>
         <ItemStyle HorizontalAlign="Center" Width="130px" />
         <HeaderStyle HorizontalAlign="Center" />
      </asp:TemplateField>
      <asp:BoundField HeaderText="Replies" DataField="ReplyCount"
         SortExpression="ReplyCount" >
         <ItemStyle HorizontalAlign="Center" Width="50px" />
         <HeaderStyle HorizontalAlign="Center" />
      </asp:BoundField>
      <asp:BoundField HeaderText="Views" DataField="ViewCount"
         SortExpression="ViewCount" >
         <ItemStyle HorizontalAlign="Center" Width="50px" />
         <HeaderStyle HorizontalAlign="Center" />
      </asp:BoundField>
      <asp:HyperLinkField
         Text="<img border='0' src='Images/MoveThread.gif' alt='Move thread' />"
         DataNavigateUrlFormatString="~/Admin/MoveThread.aspx?ThreadID={0}"
         DataNavigateUrlFields="ID">

         <ItemStyle HorizontalAlign="Center" Width="20px" />
      </asp:HyperLinkField>
      <asp:ButtonField ButtonType="Image" ImageUrl="~/Images/LockSmall.gif"
         CommandName="Close">
         <ItemStyle HorizontalAlign="Center" Width="20px" />
      </asp:ButtonField>
      <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif"
         DeleteText="Delete thread" ShowDeleteButton="True">
         <ItemStyle HorizontalAlign="Center" Width="20px" />
      </asp:CommandField>
   </Columns>
   <EmptyDataTemplate><b>No threads to show</b></EmptyDataTemplate>
</asp:GridView>

There are just a few lines of code in the page's code-behind class. In the Page_Init event handler, you set the grid's PageSize to the value read from the configuration settings, overwriting the default hard-coded value used above:

protected void Page_Init(object sender, EventArgs e)
{
   gvwThreads.PageSize = Globals.Settings.Forums.ThreadsPageSize;
}

In the Page_Load event handler there's some simple code that uses the ID passed on the querystring to load a Forum object representing the forum: The forum's title read from the object is used to set the page's Title. Then the code preselects the current forum from the DropDownList at the top, sets the ForumID parameter on the hyperlinks that create a new thread, and hides the last three GridView columns if the current user is not a power user:

protected void Page_Load(object sender, EventArgs e)
{
   if (!this.IsPostBack)
   {
      string forumID = this.Request.QueryString["ForumID"];
      lnkNewThread1.NavigateUrl = string.Format(lnkNewThread1.NavigateUrl,
         forumID);
      lnkNewThread2.NavigateUrl = lnkNewThread1.NavigateUrl;

      Forum forum = Forum.GetForumByID(int.Parse(forumID));
      this.Title = string.Format(this.Title, forum.Title);
      ddlForums.SelectedValue = forumID;

      // if the user is not an admin, editor or moderator, hide the grid's column
      // with the commands to delete, close or move a thread
      bool canEdit = (this.User.Identity.IsAuthenticated &&
         (this.User.IsInRole("Administrators") || this.User.IsInRole("Editors") ||
          this.User.IsInRole("Moderators")));
      gvwThreads.Columns[5].Visible = canEdit;
      gvwThreads.Columns[6].Visible = canEdit;
      gvwThreads.Columns[7].Visible = canEdit;
   }
}

The click on the threads' Delete button is handled automatically by the GridView and its companion ObjectdataSource control. To make the Close button work, you have to manually handle the RowCommand event handler, and call the Post.CloseThread method, as shown here:

protected void gvwThreads_RowCommand(object sender, GridViewCommandEventArgs e)
{
   if (e.CommandName == "Close")
   {
      int threadPostID = Convert.ToInt32(
         gvwThreads.DataKeys[Convert.ToInt32(e.CommandArgument)][0]);
      MB.TheBeerHouse.BLL.Forums.Post.CloseThread(threadPostID);
   }
}

The MoveThread.aspx Page

This page contains a DropDownList with the list of available forums and allows power users to move the thread (whose ID is passed on the querystring) to one of the forums, after selecting it and clicking the OK button. Figure 8-9 shows this simple user interface.

Image from book
Figure 8-9

The code markup and C# code to fill the DropDownList and to preselect the current forum was already shown in the section describing BrowseThreads.aspx, which employs a very similar forum picker. When the OK button is clicked you just read the ID of the selected forum and call the Post.MoveThread method accordingly:

protected void btnSubmit_Click(object sender, EventArgs e)
{
   int forumID = int.Parse(ddlForums.SelectedValue);
   Post.MoveThread(threadID, forumID);
   this.Response.Redirect("~/BrowseThreads.aspx?ForumID="+ forumID.ToString());
}

The ShowThread.aspx Page

This page renders a paginable grid showing all posts of the thread, whose ID is passed on the querystring. Figure 8-10 shows this GridView in action.

Image from book
Figure 8-10

Some of the information (such as the post's title, body, author, and date/time) is retrieved from the bound data retrieved by the GridView's companion ObjectDataSource (which uses Post.GetThreadByID as its SelectMethod, and GetPostCountByThread as its SelectCountMethod to support pagination). Some other data, such as the author's avatar, number of posts, and signature, are retrieved from the profile associated with the membership account named after the post author's name. The controls that show this profile data are bound to an expression that calls the GetUserProfile method, which takes the author's name and returns an instance of ProfileCommon for that user. Using the dynamically generated, strongly typed ProfileCommon object, you can easily reference the profile groups and subproperties. The following code declares the GridView's first (of two) TemplateField column, which defines the links to edit and delete the post (these will be hidden by code in the code-behind if the current user should not see them), and then defines controls bound to the user's Posts and AvatarUrl profile properties:

<asp:TemplateField ItemStyle-Width="120px" ItemStyle-CssClass="postinfo">
   <ItemTemplate>
      <div class="posttitle" style="text-align:right;">
         <asp:HyperLink runat="server" ID="lnkEditPost"
            ImageUrl="~/Images/Edit.gif"
            NavigateUrl="~/AddEditPost.aspx?ForumID={0}&ThreadID={1}&PostID={2}" />
            &nbsp;
         <asp:ImageButton runat="server" ID="btnDeletePost"
            ImageUrl="~/Images/Delete.gif"
            OnClientClick="if (confirm(‘Are you sure you want to delete this {0}?')
== false) return false;"/>&nbsp;&nbsp;
      </div>
      <asp:Literal ID="lblAddedDate" runat="server"
         Text='<%# Eval("AddedDate", "{0:D}<br/><br/>{0:T}") %>' /><hr />
      <asp:Literal ID="lblAddedBy" runat="server" Text='<%# Eval("AddedBy") %>' />
      <br /><br />
      <small><asp:Literal ID="lblPosts" runat="server"
         Text='<%# "Posts: "+
            GetUserProfile(Eval("AddedBy")).Forum.Posts.ToString() %>' />
      <asp:Literal ID="lblPosterDescription" runat="server"
         Text='<%# "<br />" +
            GetPosterDescription(GetUserProfile(Eval("AddedBy")).Forum.Posts) %>'
         Visible='<%# GetUserProfile(Eval("AddedBy")).Forum.Posts >=
            Globals.Settings.Forums.BronzePosterPosts %>'/></small><br /><br />
      <asp:Panel runat="server" ID="panAvatar" Visible='<%# GetUserProfile(
         Eval("AddedBy")).Forum.AvatarUrl.Length > 0 %>'>
      <asp:Image runat="server" ID="imgAvatar" ImageUrl='<%# GetUserProfile(
         Eval("AddedBy")).Forum.AvatarUrl %>' />
      </asp:Panel>
   </ItemTemplate>
</asp:TemplateField>

The second column renders the post's title, the body, and then the user's Signature profile property. Because the signature is in plain text, though, it first passes through a helper method named ConvertToHtml, which transforms the signature into simple HTML (it replaces carriage returns with <br/> tags, replaces multiple spaces and tabs with "&nbsp;", etc.). At the bottom it has a HyperLink to the AddEditPost.aspx page, which creates a new reply by quoting the current post's body:

<asp:TemplateField>
   <ItemTemplate>
      <div class="posttitle"><asp:Literal ID="lblTitle" runat="server"
         Text='<%# Eval("Title") %>' /></div>
      <div class="postbody">
         <asp:Literal ID="lblBody" runat="server" Text='<%# Eval("Body") %>' />
         <br /><br />
         <asp:Literal ID="lblSignature"runat="server"
            Text='<%# Helpers.ConvertToHtml(

               GetUserProfile(Eval("AddedBy")).Forum.Signature) %>' /><br /><br />
         <div style="text-align: right;">
            <asp:HyperLink runat="server" ID="lnkQuotePost"
NavigateUrl="~/AddEditPost.aspx?ForumID={0}&ThreadID={1}&QuotePostID={2}">
Quote Post</asp:HyperLink>
         </div>
      </div>
   </ItemTemplate>
</asp:TemplateField>

There's some interesting code in the code-behind class: In the preceding code you can see that the GetUserProfile method is called six times for every single post. This can cause performance problems when you consider how many times this might execute in one page cycle. The same thread will likely have multiple posts by the same user: In a typical thread of 20 posts, four of them might be from the same user. This means we make 24 calls to GetUserProfile for the same user. This method uses ASP.NET's Profile.GetProfile method to retrieve a ProfileCommon object for the specified user, which unfortunately doesn't cache the result. This means that every time you call Profile.GetProfile, it will run a query to SQL Server to retrieve the user's profile, and then build the ProfileCommon object to be returned. In our situation, this would be an incredible waste of resources, because after the first query for a specific user, the next 23 queries for that user would produce the same result. To prevent this kind of waste, we'll use the GetUserProfile method to wrap the call to Profile.GetProfile by adding simple caching support that will last as long as the page's lifetime. It uses a Hashtable, which uses the username as a key, and the ProfileCommon object as the value; if the requested profile is not found in the Hashtable when the method is called, it forwards the call to Profile.GetProfile and then saves the result in the Hashtable for future needs. Here's how it's implemented:

Hashtable profiles = new Hashtable();

protected ProfileCommon GetUserProfile(object userName)
{
   string name = (string)userName;
   if (!profiles.Contains(name))
   {
      ProfileCommon profile = this.Profile.GetProfile(name);
      profiles.Add(name, profile);
      return profile;
   }
   else
      return profiles[userName] as ProfileCommon;
}

There's another helper method on the page, GetPosterDescription, which returns the user's status description according to the user's number of posts. It compares the number with the values of the GoldPosterPosts, SilverPosterPosts, and BronzePosterPosts configuration settings, and returns the appropriate description:

protected string GetPosterDescription(int posts)
{
   if (posts >= Globals.Settings.Forums.GoldPosterPosts)
      return Globals.Settings.Forums.GoldPosterDescription;
   else if (posts >= Globals.Settings.Forums.SilverPosterPosts)
      return Globals.Settings.Forums.SilverPosterDescription;

   if (posts >= Globals.Settings.Forums.BronzePosterPosts)
      return Globals.Settings.Forums.BronzePosterDescription;
   else
      return "";
}

The rest of the page's code-behind is pretty typical. For example, you handle the grid's RowDataBound event to show, or hide, the post's edit link according to whether the current user is the post's author or a power user, or just another user. It also sets the Delete button's CommandName property to DeleteThread or DeletePost, according to whether the post is the first post of the thread (and thus represents the whole thread) or not, and shows or hides the link to quote the post according to whether the thread is closed. The following code shows this:

protected void gvwPosts_RowDataBound(object sender, GridViewRowEventArgs e)
{
   if (e.Row.RowType == DataControlRowType.DataRow)
   {
      Post post = e.Row.DataItem as Post;
      int threadID = (post.IsFirstPost ? post.ID : post.ParentPostID);

      // the link for editing the post is visible to the post's author, and to
      // administrators, editors and moderators
      HyperLink lnkEditPost = e.Row.FindControl("lnkEditPost") as HyperLink;
      lnkEditPost.NavigateUrl = string.Format(lnkEditPost.NavigateUrl,
         post.ForumID, threadID, post.ID);
      lnkEditPost.Visible = (this.User.Identity.IsAuthenticated &&
         (this.User.Identity.Name.ToLower().Equals(post.AddedBy.ToLower()) ||
         (this.User.IsInRole("Administrators") || this.User.IsInRole("Editors") ||
         this.User.IsInRole("Moderators"))));

      // the link for deleting the thread/post is visible only to administrators,
      // editors and moderators
      ImageButton btnDeletePost = e.Row.FindControl(
         "btnDeletePost") as ImageButton;
      btnDeletePost.OnClientClick = string.Format(btnDeletePost.OnClientClick,
         post.IsFirstPost ? "entire thread" : "post");
      btnDeletePost.CommandName = (post.IsFirstPost ?
         "DeleteThread" : "DeletePost");
      btnDeletePost.CommandArgument = post.ID.ToString();
      btnDeletePost.Visible = (this.User.IsInRole("Administrators") ||
         this.User.IsInRole("Editors") || this.User.IsInRole("Moderators"));

      // if the thread is not closed, show the link to quote the post
      HyperLink lnkQuotePost = e.Row.FindControl("lnkQuotePost") as HyperLink;
      lnkQuotePost.NavigateUrl = string.Format(lnkQuotePost.NavigateUrl,
         post.ForumID, threadID, post.ID);
      lnkQuotePost.Visible = !(post.IsFirstPost ?
         post.Closed : post.ParentPost.Closed);
   }
}

You also handle the grid's RowCommand event to process the click action of the post's Delete button. You always call Post.DeletePost to delete a single post, or an entire thread (the situation is indicated by the CommandName property of the method's e parameter), but in the first case you just rebind the GridView to its data source, whereas in the second case you redirect to the page that browses the thread's parent forum's threads after deleting it:

protected void gvwPosts_RowCommand(object sender, GridViewCommandEventArgs e)
{
   if (e.CommandName == "DeleteThread")
   {
      int threadPostID = Convert.ToInt32(e.CommandArgument);
      int forumID = Post.GetPostByID(threadPostID).ID;
      Post.DeletePost(threadPostID);
      this.Response.Redirect("BrowseThreads.aspx?ForumID="+ forumID.ToString());
   }
   else if (e.CommandName == "DeletePost")
   {
      int postID = Convert.ToInt32(e.CommandArgument);
      Post.DeletePost(postID);
      gvwPosts.PageIndex = 0;
      gvwPosts.DataBind();
   }
}

Producing and Consuming RSS Feeds

The forums module includes the GetThreadsRss.aspx page, which returns an RSS feed of the forums' threads, either for a single subforum or for all subforums, depending on whether a ForumID parameter is passed on the querystring or not. It also supports a SortExpr parameter that specifies one of the supported sort expressions, such as "LastPostDate DESC" (the default), "ReplyCount DESC", etc. The page's markup uses a Repeater control to define the XML template of the RSS feed, and then binds to the thread list retrieved by calling the Post.GetThreads business method. This same technique has already been used for the GetArticlesRss.aspx page in Chapter 5. As usual, the code download for this book has the complete implementation for this page, and all the other pages.

Once you have the page working, you can use the generic RssReader.ascx user control (developed in Chapter 5) to consume some feeds, and therefore publish the list of threads (sorted in different ways) on various parts of the site. I've added two feeds on the ShowForums.aspx page, to show the list of "n" newest threads, and the "n" threads with the most replies (where "n" is specified in the web.config file), but you can also easily add them to the site's home page or to any other page you wish. Here's the code to consume the two feeds:

<table width="100%" cellpadding="4" cellspacing="0">
   <tr>
      <td width="50%">
         <div style="border-right: solid 1px;">
         <mb:RssReader id="rssLatestThreads" runat="server" Title="Latest Threads"
            RssUrl="~/GetThreadsRss.aspx?SortExpr=LastPostDate DESC" />
         </div>
      </td>
      <td width="50%">
         <mb:RssReader id="rssMostActiveThreads" runat="server"
            Title="Most Active Threads"
            RssUrl="~/GetThreadsRss.aspx?SortExpr=ReplyCount DESC" />
      </td>
   </tr>
</table>

Figure 8-11 shows a screenshot of the two lists.

Image from book
Figure 8-11

Securing the Forum Module

While developing the pages, we've already inserted many checks to ensure that only certain users can perform actions such as closing, moving, deleting, and editing posts. Programmatic security is required in some circumstances, but in other cases it suffices to use declarative security to allow or deny access to a resource by a given user or role. For example, the AddEditPost.aspx page must never be accessed by anonymous users in this implementation, and you can easily enforce this restriction by adding a declaration to the web.config file found in the site's root folder: You just need to add a new <location> section with a few <allow> and <deny> elements. There's one other aspect of the AddEditPost.aspx page that should be considered: If a member doesn't respect the site's policies, and repeatedly submits messages with spam or offensive language, then we'd like to be able to ban the member from adding any new posts. One way to do this is to block messages coming from that IP address, but it's even better to block that user account from accessing this page. However, we don't want to block that account completely; otherwise, that member would lose access to any other section of the site, which would be too restrictive for that particular crime! The easiest way to handle this is to add a new role called "Posters" to all new users at registration time, and then add a declarative restriction to web.config that ensures that only users who belong to the Administrators, Editors, Moderators, or Posters role can access the AddEditPost.aspx page, as shown below:

<location path="AddEditPost.aspx">
   <system.web>
      <authorization>
         <allow roles="Administrators,Editors,Moderators,Posters" />
         <deny users="*"/>
      </authorization>
   </system.web>
</location>

To automatically add a user to the Posters role immediately after the user has registered, you must modify the Register.aspx page developed in Chapter 4 to handle the CreateUserWizard's CreatedUser event (which is raised just after the user has been created), and then call the AddUserToRole method of ASP.NET's Roles class, as shown below:

protected void CreateUserWizard1_CreatedUser(object sender, EventArgs e)
{
   Roles.AddUserToRole(CreateUserWizard1.UserName, "Posters");
}

In the future, if you want to remove a given user's right to post new messages, you only need to remove the user from the Posters role, using the EditUser.aspx administration page developed in Chapter 4. This module's administration page also has some <location> section restrictions in the web.config file located under the Admin folder to ensure that only Administrators, Editors, and Moderators can access them.


Previous Page
Next Page


JavaScript EditorFreeware javascript editor     Javascript code