In coding the solution, we'll follow the same path we used in the "Design" section: from database tables and stored procedure creation, to the implementation of security, passing through the DAL, BLL, and lastly the user interface.
Creating the database tables is straightforward with Visual Studio's integrated Server Explorer and database manager, so we won't cover it here. You can refer to the tables in the "Design" section to see all the settings for each field. In the downloadable code file for this book, you will find the complete DB ready to go. Instead, here you'll create relationships between the tables and write some stored procedures.
You create a new diagram from the Server Explorer: Drill down from Data Connections to your database (if you don't see your database you can add it as a new Data Connection), and then Database Diagrams. Right-click on Database Diagrams and select Add New Diagram. By following the wizard, you can add the tbh_Categories, tbh_Articles, and tbh_Comments tables to your diagram. As soon as the three tables are added to the underlying window, Server Explorer should recognize a relationship between tbh_Categories and tbh_Articles, and between tbh_Articles and tbh_Comments, and automatically create a parent-child relationship between them over the correct fields. However, if it does not, click on the tbh_Articles' CategoryID field and drag and drop the icons that appear over the tbh_Categories table. Once you release the button, a dialog with the relationship's properties appears, and you can ensure that the foreign key is the tbh_Articles' CategoryID field, while the primary key is tbh_Categories' CategoryID. Once the connection is set up, you also have to ensure that when a category record is deleted or updated, the action is cascaded to the child table too. To do this, select the connection, go to the Properties window (just press F4), and set the Delete Rule and Update Rule settings to Cascade, as shown in Figure 5-5.
The Update Rule = Cascade option ensures that if you change the CategoryID primary key in the tbh_Categories table, this change is propagated to the foreign keys in the tbh_Articles table. The primary key should never be changed, as it is an identity and the administration pages won't allow you to change it. The Delete Rule = Cascade option ensures that if you delete a category, all the related articles are deleted as well. This means you won't have to delete the child articles from the stored procedure that deletes a category because they will be deleted automatically. This option is very important and must be checked, because if you forget it you'll end up with a database filled with unreachable articles because the parent category no longer exists!
Now you have to create a relationship between tbh_Comments and tbh_Articles, based on the ArticleID field of both tables. As before, click the tbh_Comments' ArticleID field, drag and drop the icon over the tbh_Articles table and complete the Properties dialog as before. When you're done with the diagram, go up to the tab, right-click on it, and save the diagram. Make sure you let it change your tables as specified in the diagram.
This section presents the code for some stored procedures. It covers a representative sample of the procedures, instead of every one, because the code is very similar whether you add, edit, or delete a category or article. The stored procedures that work with the articles are more complex than the respective procedures that manage the categories, because they have to join two tables, they have more parameters, and they support pagination, so these are the ones covered here.
The following code inserts a new row in the tbh_Articles table and returns the ID of the added row through the output parameter:
CREATE PROCEDURE dbo.tbh_Articles_InsertArticle ( @AddedDate datetime, @AddedBy nvarchar(256), @CategoryID int, @Title nvarchar(256), @Abstract nvarchar(4000), @Body ntext, @Country nvarchar(256), @State nvarchar(256), @City nvarchar(256), @ReleaseDate datetime, @ExpireDate datetime, @Approved bit, @Listed bit, @CommentsEnabled bit, @OnlyForMembers bit, @ArticleID int OUTPUT ) AS SET NOCOUNT ON INSERT INTO tbh_Articles (AddedDate, AddedBy, CategoryID, Title, Abstract, Body, Country, State, City, ReleaseDate, ExpireDate, Approved, Listed, CommentsEnabled, OnlyForMembers) VALUES (@AddedDate, @AddedBy, @CategoryID, @Title, @Abstract, @Body, @Country, @State, @City, @ReleaseDate, @ExpireDate, @Approved, @Listed, @CommentsEnabled, @OnlyForMembers) SET @ArticleID = scope_identity()
The procedure is pretty simple, but a couple of details are worth underlining. The first is that I'm using the scope_identity() function to retrieve the last ID inserted into the table, instead of the IDENTITY system function. IDENTITY is probably more popular but it has a problem: It returns the last ID generated in the current connection, but not necessarily in the current scope (where the scope is the stored procedure in this case). That is, it could return the ID of a record generated by a trigger that runs on the same connection, and this is not what we want! If we use scope_identity, we get the ID of the last record created in the current scope, which is what we want.
The other detail is the use of the SET NOCOUNT ON statement, to stop SQL Server from indicating the number of rows affected by the T-SQL statements as part of the result. When running INSERTs or SELECTs this value is typically not needed, so if you avoid retrieving it you'll improve performance a bit. However, the row count is useful when running UPDATE statements, because the code on the client computer (typically your C# program on the web server) can examine the number of rows affected to determine whether the stored procedure actually updated the proper number of rows you expected it to update.
This procedure updates many fields of a row, except for the ID, of course, and the count-related fields such as ViewCount, Votes and TotalRating, because they are not supposed to be updated directly by the editor from the Edit Article page:
CREATE PROCEDURE dbo.tbh_Articles_UpdateArticle ( @ArticleID int, @CategoryID int, @Title nvarchar(256), @Abstract nvarchar(4000), @Body ntext, @Country nvarchar(256), @State nvarchar(256), @City nvarchar(256), @ReleaseDate datetime, @ExpireDate datetime, @Approved bit, @Listed bit, @CommentsEnabled bit, @OnlyForMembers bit ) AS UPDATE tbh_Articles SET CategoryID = @CategoryID, Title = @Title, Abstract = @abstract, Body = @Body, Country = @Country, State = @State, City = @City, ReleaseDate = @ReleaseDate, ExpireDate = @ExpireDate, Approved = @Approved, Listed = @Listed, CommentsEnabled = @CommentsEnabled, OnlyForMembers = @OnlyForMembers WHERE ArticleID = @ArticleID
This procedure works exactly the same way as the last procedure, but the only field updated is the Approved field. This is useful when the administrator or editor only needs to approve an article, without having to supply the current values for all the other fields to the preceding procedure:
CREATE PROCEDURE dbo.tbh_Articles_ApproveArticle ( @ArticleID int ) AS UPDATE tbh_Articles SET Approved = 1 WHERE ArticleID = @ArticleID
This procedure rates an article by incrementing the Votes field for the specified article, and at the same time it tallies the article's TotalRating field by adding in the new rating value:
CREATE PROCEDURE dbo.tbh_Articles_InsertVote ( @ArticleID int, @Rating smallint ) AS UPDATE tbh_Articles SET Votes = Votes + 1, TotalRating = TotalRating + @Rating WHERE ArticleID = @ArticleID
This procedure increments the ViewCount field for the specified article:
CREATE PROCEDURE dbo.tbh_Articles_IncrementViewCount ( @ArticleID int ) AS UPDATE tbh_Articles SET ViewCount = ViewCount + 1 WHERE ArticleID = @ArticleID
This is the easiest procedure: It just deletes the row with the specified ArticleID:
This procedure returns all fields of the specified article. It joins the tbh_Articles and tbh_Categories tables so that it can also retrieve the title of the parent category:
CREATE PROCEDURE dbo.tbh_Articles_GetArticleByID ( @ArticleID int ) AS SET NOCOUNT ON SELECT tbh_Articles.ArticleID, tbh_Articles.AddedDate, tbh_Articles.AddedBy, tbh_Articles.CategoryID, tbh_Articles.Title, tbh_Articles.Abstract, tbh_Articles.Body, tbh_Articles.Country, tbh_Articles.State, tbh_Articles.City, tbh_Articles.ReleaseDate, tbh_Articles.ExpireDate, tbh_Articles.Approved, tbh_Articles.Listed, tbh_Articles.CommentsEnabled, tbh_Articles.OnlyForMembers, tbh_Articles.ViewCount, tbh_Articles.Votes, tbh_Articles.TotalRating, tbh_Categories.Title AS CategoryTitle FROM tbh_Articles INNER JOIN tbh_Categories ON tbh_Articles.CategoryID = tbh_Categories.CategoryID WHERE ArticleID = @ArticleID
The fun starts here! This procedure returns a "virtual page" of articles, from any category —the page index and page size values are input parameters. Before getting into the code for this procedure I want to explain the old way we implemented this type of functionality using SQL Server 2000. In the first edition of this book we used custom pagination for the forums module, and we implemented it by using one of the various techniques available at that time: temporary tables. You would first create a temporary table, with the ArticleID field from the tbh_Articles table, plus a new ID column that you would declare as IDENTITY, so that its value is automatically set with an auto-increment number for each record you add. Then you would insert into the temporary #TempArticles table the ArticleID values of all records from tbh_Articles. Finally, you would do a SELECT on the temporary table joined with the tbh_Articles table, making the filter on the temporary table's ID field. The #TempArticles table with the IDENTITY ID column was necessary because you needed a column whose IDs would go from 1 to the total number of records, without holes in the middle. You could not have used the ArticleID column directly to do this because you may have had some deleted records. Following is a sample implementation that we might have used following this approach:
CREATE PROCEDURE dbo.tbh_Articles_GetArticles ( @PageIndex int, @PageSize int ) AS SET NOCOUNT ON -- create a temporary table CREATE TABLE #TempArticles ( ID int IDENTITY(1,1), ArticleID int ) -- populate the temporary table INSERT INTO #TempArticles (ArticleID) SELECT ArticleID FROM tbh_Articles ORDER BY ReleaseDate DESC -- get a page of records from the temporary table, -- and join them with the real table SELECT ID, tbh_Articles.* FROM #TempArticles INNER JOIN tbh_Articles ON tbh_Articles.ArticleID = #TempArticles.ArticleID WHERE ID BETWEEN (@PageIndex*@PageSize+1) AND ((@PageIndex+1)*@PageSize)
This technique is still my favorite choice when working with SQL Server 2000 databases, but in SQL Server 2005 (including the free Express Edition) there is a much simpler solution that leverages the new ROW_NUMBER() function. As its name suggests, it returns a consecutive number sequence, starting from 1, which provides a unique number returned by each row of an ordered query. You can use it to add a calculated RowNum field to a SELECT statement, and then select all rows whose calculated RowNum is between the lower and upper bound of the specified page. Following is the complete stored procedure using this new ROW_NUMBER() function:
CREATE PROCEDURE dbo.tbh_Articles_GetArticles ( @PageIndex int, @PageSize int ) AS SET NOCOUNT ON SELECT * FROM ( SELECT tbh_Articles.ArticleID, tbh_Articles.AddedDate, tbh_Articles.AddedBy, tbh_Articles.CategoryID, tbh_Articles.Title, tbh_Articles.Abstract, tbh_Articles.Body, tbh_Articles.Country, tbh_Articles.State, tbh_Articles.City, tbh_Articles.ReleaseDate, tbh_Articles.ExpireDate, tbh_Articles.Approved, tbh_Articles.Listed, tbh_Articles.CommentsEnabled, tbh_Articles.OnlyForMembers, tbh_Articles.ViewCount, tbh_Articles.Votes, tbh_Articles.TotalRating, tbh_Categories.Title AS CategoryTitle, ROW_NUMBER() OVER (ORDER BY ReleaseDate DESC) AS RowNum FROM tbh_Articles INNER JOIN tbh_Categories ON tbh_Articles.CategoryID = tbh_Categories.CategoryID ) Articles WHERE Articles.RowNum BETWEEN (@PageIndex*@PageSize+1) AND ((@PageIndex+1)*@PageSize) ORDER BY ReleaseDate DESC
You might not be familiar with the usage of a SELECT statement within the FROM clause of an outer SELECT statement — this is called an in-line view and it's a special kind of subquery; it can be thought of as being an automatically created temporary table named Articles. The ROW_NUMBER() function is being used in this inner query, and it is assigned a column alias named RowNum. This RowNum alias is then referenced in the outer query's WHERE clause. The ORDER BY specification in the ROW_NUMBER() function's OVER clause specifies the sorting criteria for the inner query, and this must match the ORDER BY clause in the outer query. The rows in this case are always sorted by ReleaseDate in descending order (i.e., from the newest to the oldest). This syntax seems a little odd at first, but it's a very efficient way to handle paging, and it's much easier to implement than the older techniques.
This procedure simply returns the total number of rows in the tbh_Articles table. This count is needed by our pagination code because the grid control, which will be used in the user interface, must know how many items there are so it can correctly show the links to navigate through the pages of the resultset:
CREATE PROCEDURE dbo.tbh_Articles_GetArticleCount AS SET NOCOUNT ON SELECT COUNT(*) FROM tbh_Articles
This procedure returns a page of published articles from a specific category. The code for the pagination is the same as that just shown, but here we're adding filters to include only articles from a specific category: approved, listed, and those for which the current date is between the ReleaseDate and ExpireDate:
CREATE PROCEDURE dbo.tbh_Articles_GetPublishedArticlesByCategory ( @CategoryID int, @CurrentDate datetime, @PageIndex int, @PageSize int ) AS SET NOCOUNT ON SELECT * FROM ( SELECT tbh_Articles.ArticleID, tbh_Articles.AddedDate, tbh_Articles.AddedBy, tbh_Articles.CategoryID, tbh_Articles.Title, tbh_Articles.Abstract, tbh_Articles.Body, tbh_Articles.Country, tbh_Articles.State, tbh_Articles.City, tbh_Articles.ReleaseDate, tbh_Articles.ExpireDate, tbh_Articles.Approved, tbh_Articles.Listed, tbh_Articles.CommentsEnabled, tbh_Articles.OnlyForMembers, tbh_Articles.ViewCount, tbh_Articles.Votes, tbh_Articles.TotalRating, tbh_Categories.Title AS CategoryTitle, ROW_NUMBER() OVER (ORDER BY ReleaseDate DESC) AS RowNum FROM tbh_Articles INNER JOIN tbh_Categories ON tbh_Articles.CategoryID = tbh_Categories.CategoryID WHERE Approved = 1 AND Listed = 1 AND ReleaseDate <= @CurrentDate AND ExpireDate > @CurrentDate AND tbh_Articles.CategoryID = @CategoryID ) Articles WHERE Articles.RowNum BETWEEN (@PageIndex*@PageSize+1) AND ((@PageIndex+1)*@PageSize) ORDER BY ReleaseDate DESC
This is the procedure with the most parameters, and thus is the most complex. We're passing the current date to the stored procedure as a parameter. You might think that this wouldn't be necessary, as you can easily get the current date using the T-SQL GETDATE() function. That's true, but the function would return the database server's current date, which may be different from the front-end's or the business logic server's date. We're interested in the current date of the server on which the application runs (typically the web server), and therefore it is safer to retrieve the date on that server and pass it as an input to the stored procedure. This is also handy in cases where an administrator or editor might want to use a future date as the current date to see how the site would look on that day (for example: Will the articles be published correctly on a specific date? Will other articles be retired after a specific date?). This will not be implemented in the proposed solution, but it can be a useful enhancement you might want to consider.
We also need the tbh_Articles_GetArticlesByCategory and tbh_Articles_GetPublished Articles procedures, but they are very similar to this procedure and tbh_Articles_GetArticles, so I won't show the code here. The full code is provided in the code download file for this book.
This procedure counts how many published articles exist in a specific category:
CREATE PROCEDURE dbo.tbh_Articles_GetPublishedArticleCountByCategory ( @CategoryID int, @CurrentDate datetime ) AS SET NOCOUNT ON SELECT COUNT(*) FROM tbh_Articles WHERE CategoryID = @CategoryID AND Approved = 1 AND Listed = 1 AND ReleaseDate <= @CurrentDate AND ExpireDate > @CurrentDate
The ArticlesElement class is implemented in the ~/App_Code/ConfigSection.cs file. It descends from System.Configuration.ConfigurationElement and implements the properties that map the attributes of the <articles> element under the <theBeerHouse> custom section in the web.config file. The properties, listed and described in the "Design" section, are bound to the XML settings by means of the ConfigurationProperty attribute. Here's its code:
public class ArticlesElement : ConfigurationElement { [ConfigurationProperty("connectionStringName")] public string ConnectionStringName { get { return (string)base["connectionStringName"]; } set { base["connectionStringName"] = value; } } public string ConnectionString { get { string connStringName = (string.IsNullOrEmpty(this.ConnectionStringName) ? Globals.Settings.DefaultConnectionStringName : this.ConnectionStringName); return WebConfigurationManager.ConnectionStrings[ connStringName].ConnectionString; } } [ConfigurationProperty("providerType", DefaultValue = "MB.TheBeerHouse.DAL.SqlClient.SqlArticlesProvider")] public string ProviderType { get { return (string)base["providerType"]; } set { base["providerType"] = value; } } [ConfigurationProperty("pageSize", DefaultValue = "10")] public int PageSize { get { return (int)base["pageSize"]; } set { base["pageSize"] = value; } } [ConfigurationProperty("rssItems", DefaultValue = "5")] public int RssItems { get { return (int)base["rssItems"]; } set { base["rssItems"] = value; } } [ConfigurationProperty("enableCaching", DefaultValue = "true")] public bool EnableCaching { get { return (bool)base["enableCaching"]; } set { base["enableCaching"] = value; } } [ConfigurationProperty("cacheDuration")] public int CacheDuration { get { int duration = (int)base["cacheDuration"]; return (duration > 0 ? duration : Globals.Settings.DefaultCacheDuration); } set { base["cacheDuration"] = value; } } }
The ConnectionString property does not directly read/write a setting from/to the configuration file, but rather returns the value of the entry in the web.config's <connectionStrings> section identified by the name indicated in the <articles>'s connectionStringName attribute, or the <theBeerHouse>'s defaultConnectionStringName if the first setting is not present. The CacheDuration property returns the <articles>'s cacheDuration setting if it is greater than zero, or the <theBeerHouse>'s defaultCacheDuration setting otherwise.
DefaultConnectionStringName and DefaultCacheDuration are two new properties of the TheBeerHouseSection created in Chapter 3, now modified as shown here:
public class TheBeerHouseSection : ConfigurationSection { [ConfigurationProperty("contactForm", IsRequired=true)] public ContactFormElement ContactForm { get { return (ContactFormElement) base["contactForm"]; } } [ConfigurationProperty("defaultConnectionStringName", DefaultValue = "LocalSqlServer")] public string DefaultConnectionStringName { get { return (string)base["defaultConnectionStringName"]; } set { base["connectionStdefaultConnectionStringNameringName"] = value; } } [ConfigurationProperty("defaultCacheDuration", DefaultValue = "600")] public int DefaultCacheDuration { get { return (int)base["defaultCacheDuration"]; } set { base["defaultCacheDuration"] = value; } } [ConfigurationProperty("articles", IsRequired = true)] public ArticlesElement Articles { get { return (ArticlesElement)base["articles"]; } } }
The updated <theBeerHouse> section in web.config looks like this:
<theBeerHouse defaultConnectionStringName="LocalSqlServer"> <articles providerType="MB.TheBeerHouse.DAL.SqlClient.SqlArticlesProvider" pageSize="10" rssItems="10" enableCaching="true" cacheDuration="300" /> <contactForm mailTo="webmaster@effectivedotnet.com"/> </theBeerHouse>
To read the settings from code you can do it this way: Globals.Settings.Articles.RssItems.
Now that the DB is complete, we'll start writing the C# code for the data access layer. As mentioned earlier, we won't be putting this code in a separate assembly, as we did for the previous edition of the book. Instead, we'll be putting the C# files under the special App_Code folder so they will be compiled automatically when the application is run, thereby simplifying deployment and allowing us to take advantage of the new "Edit and Continue" functionality in Visual Studio 2005. For small to medium-size sites, this approach is very handy and practical. However, for larger enterprise-level sites it might be better to organize and compile this code separately so the UI developer can easily reference the separate assembly.
This section presents some classes of the DAL, but not all of them. The ArticleDetails, CategoryDetails, and CommentDetails classes have the same structure, so there's no reason to show each one. The same goes for the methods to retrieve, insert, update, and delete records in the tbh_Articles, tbh_Categories and tbh_Comments tables. Therefore, as I did before for the stored procedure, I'll only show the code for the articles; you can refer to the code download for the rest of the code that deals with categories and comments.
This class is implemented in the ~/App_Code/DAL/ArticleDetails.cs file. It wraps the article's entire data read from tbh_Articles. The constructor takes values as inputs, and saves them in the object's properties (the code that defines many properties is omitted for brevity's sake):
namespace MB.TheBeerHouse.DAL { public class ArticleDetails { public ArticleDetails() { } public ArticleDetails(int id, DateTime addedDate, string addedBy, int categoryID, string categoryTitle, string title, string artabstract, string body, string country, string state, string city, DateTime releaseDate, DateTime expireDate, bool approved, bool listed, bool commentsEnabled, bool onlyForMembers, int viewCount, int votes, int totalRating) { this.ID = id; this.AddedDate = addedDate; this.AddedBy = addedBy; this.CategoryID = categoryID; this.CategoryTitle = categoryTitle; this.Title = title; this.Abstract = artabstract; this.Body = body; this.Country = country; this.State = state; this.City = city; this.ReleaseDate = releaseDate; this.ExpireDate = expireDate; this.Approved = approved; this.Listed = listed; this.CommentsEnabled = commentsEnabled; this.OnlyForMembers = onlyForMembers; this.ViewCount = viewCount; this.Votes = votes; this.TotalRating = totalRating; } private int _id = 0; public int ID { get { return _id;} set { _id = value;} } private DateTime _addedDate = DateTime.Now; public DateTime AddedDate { get { return _addedDate; } set { _addedDate = value; } } private string _addedBy = ""; public string AddedBy { get { return _addedBy; } set { _addedBy = value; } } private int _categoryID = 0; public int CategoryID { get { return _categoryID; } set { _categoryID = value; } } private string _categoryTitle = ""; public string CategoryTitle { get { return _categoryTitle; } set { _categoryTitle = value; } } private string _title = ""; public string Title { get { return _title; } set { _title = value; } } /* The code for all other properties would go here... */Country, }
ArticlesProvider is an abstract class that defines a set of abstract CRUD (create, retrieve, update, delete) methods that will be implemented by a concrete class for a specific data store. We'll be using SQL Server as the data store, of course, but this abstract class has no knowledge of that. This class will be stored in the ~/App_Code/DAL/ArticlesProvider.cs file. It descends from the DataAccess class, and thus has ConnectionString, EnableCaching, and CacheDuration, which are set from within the constructor with the values read from the settings. Here's how it starts:
namespace MB.TheBeerHouse.DAL { public abstract class ArticlesProvider : DataAccess { public ArticlesProvider() { this.ConnectionString = Globals.Settings.Articles.ConnectionString; this.EnableCaching = Globals.Settings.Articles.EnableCaching; this.CacheDuration = Globals.Settings.Articles.CacheDuration; } // methods that work with categories public abstract List<CategoryDetails> GetCategories(); public abstract CategoryDetails GetCategoryByID(int categoryID); public abstract bool DeleteCategory(int categoryID); public abstract bool UpdateCategory(CategoryDetails category); public abstract int InsertCategory(CategoryDetails category); // methods that work with articles public abstract List<ArticleDetails> GetArticles( int pageIndex, int pageSize); public abstract List<ArticleDetails> GetArticles( int categoryID, int pageIndex, int pageSize); public abstract int GetArticleCount(); public abstract int GetArticleCount(int categoryID); public abstract List<ArticleDetails> GetPublishedArticles( DateTime currentDate, int pageIndex, int pageSize); public abstract List<ArticleDetails> GetPublishedArticles( int categoryID, DateTime currentDate, int pageIndex, int pageSize); public abstract int GetPublishedArticleCount(DateTime currentDate); public abstract int GetPublishedArticleCount( int categoryID, DateTime currentDate); public abstract ArticleDetails GetArticleByID(int articleID); public abstract bool DeleteArticle(int articleID); public abstract bool UpdateArticle(ArticleDetails article); public abstract int InsertArticle(ArticleDetails article); public abstract bool ApproveArticle (int articleID); public abstract bool IncrementArticleViewCount(int articleID); public abstract bool RateArticle(int articleID, int rating); public abstract string GetArticleBody(int articleID); // methods that work with comments public abstract List<CommentDetails> GetComments( int pageIndex, int pageSize); public abstract List<CommentDetails> GetComments( int articleID, int pageIndex, int pageSize); public abstract int GetCommentCount(); public abstract int GetCommentCount(int articleID); public abstract CommentDetails GetCommentByID(int commentID); public abstract bool DeleteComment(int commentID); public abstract bool UpdateComment(CommentDetails article); public abstract int InsertComment(CommentDetails article);
Besides the abstract methods, this ArticlesProvider class exposes some protected virtual methods that implement certain functionality that can be overridden in a subclass if the need arises. The GetArticleFromReader method reads the current record pointed to by the DataReader passed as an input, and uses its data to fill a new ArticleDetails object. An overloaded version allows us to specify whether the tbh_Articles' field must be read — remember that this field is not retrieved by the stored procedures that return multiple records (GetArticles, GetArticlesByCategory, etc.), so in those cases the method will be called with false as the second parameter:
/// <summary>Returns a new ArticleDetails instance filled with the /// DataReader's current record data</summary> protected virtual ArticleDetails GetArticleFromReader(IDataReader reader) { return GetArticleFromReader(reader, true); } protected virtual ArticleDetails GetArticleFromReader( IDataReader reader, bool readBody) { ArticleDetails article = new ArticleDetails( (int)reader["ArticleID"], (DateTime)reader["AddedDate"], reader["AddedBy"].ToString(), (int)reader["CategoryID"], reader["CategoryTitle"].ToString(), reader["Title"].ToString(), reader["Abstract"].ToString(), null, reader["Country"].ToString(), reader["State"].ToString(), reader["City"].ToString(), (DateTime)reader["ReleaseDate"], (DateTime)reader["ExpireDate"], (bool)reader["Approved"], (bool)reader["Listed"], (bool)reader["CommentsEnabled"], (bool)reader["OnlyForMembers"], (int)reader["ViewCount"], (int)reader["Votes"], (int)reader["TotalRating"]); if (readBody) article.Body = reader["Body"].ToString(); return article; }
Note that the first input parameter is of type IDataReader, a generalized interface that is implemented by OleDbDataReader, SqlDataReader, OracleDataReader, etc. This allows the concrete classes to pass their DB-specific DataReader objects to this method, which will operate with any of them because it knows that the specific reader object passed in will implement the methods of IDataReader. This style of coding is called coding to an interface. The second protected method, GetArticleCollection FromReader, returns a generic list collection of ArticleDetails objects filled with the data of all records in a DataReader — it does this by calling GetArticleFromReader until the DataReader has no more records:
/// <summary>Returns a collection of ArticleDetails objects with the /// data read from the input DataReader</summary> protected virtual List<ArticleDetails> GetArticleCollectionFromReader( IDataReader reader) { return GetArticleCollectionFromReader(reader, true); } protected virtual List<ArticleDetails> GetArticleCollectionFromReader( IDataReader reader, bool readBody) { List<ArticleDetails> articles = new List<ArticleDetails>(); while (reader.Read()) articles.Add(GetArticleFromReader(reader, readBody)); return articles; }
I won't show them here, but the code download has similar methods for filling CategoryDetails and CommentDetails objects from a DataReader. Finally, there is a static Instance property that uses reflection to create an instance of the concrete provider class indicated in the configuration file:
static private ArticlesProvider _instance = null; /// <summary> /// Returns an instance of the provider type specified in the config file /// </summary> static public ArticlesProvider Instance { get { if (_instance == null) _instance = (ArticlesProvider)Activator.CreateInstance( Type.GetType(Globals.Settings.Articles.ProviderType)); return _instance; } } } }
Once the provider is created for the first time, it is saved in a static private property and won't be recreated again until the web application is shut down and restarted (for example, when IIS is stopped and restarted, or when the web.config file is changed).
This class, implemented in the file ~/App_Code/DAL/SqlClient/SqlArticlesProvider.cs, provides the DAL code specific to SQL Server. Some of the stored procedures that will be called here use SQL Server 2005—specific functions, such as the ROW_NUMBER() windowing function introduced earlier. However, you can change those procedures to use T-SQL code that is compatible with SQL Server 2000 if desired. One of the advantages of using stored procedures is that you can change their code later without touching the C#, which would require recompilation and redeployment of some DLLs.
All the code of this provider is pretty simple, as there is basically one method for each of the stored procedures designed and implemented earlier. I'll show you a few of the methods related to articles; you can study the downloadable code for the rest. The GetArticles method presented below illustrates the general pattern:
namespace MB.TheBeerHouse.DAL.SqlClient { public class SqlArticlesProvider : ArticlesProvider { /// <summary> /// Retrieves all articles for the specified category /// </summary> public override List<ArticleDetails> GetArticles( int categoryID, int pageIndex, int pageSize) { using (SqlConnection cn = new SqlConnection(this.ConnectionString)) { SqlCommand cmd = new SqlCommand( "tbh_Articles_GetArticlesByCategory", cn); cmd.Parameters.Add("@CategoryID", SqlDbType.Int).Value = categoryID; cmd.Parameters.Add("@PageIndex", SqlDbType.Int).Value = pageIndex; cmd.Parameters.Add("@PageSize", SqlDbType.Int).Value = pageSize; cmd.CommandType = CommandType.StoredProcedure; cn.Open(); return GetArticleCollectionFromReader(ExecuteReader(cmd), false); } }
The SqlConnection class implements the IDisposable interface, which means that it provides the Dispose method that closes the connection if it is open. The connection object in this method is created within a using statement, so that it is automatically disposed when the block ends, avoiding the need to manually call Dispose. This ensures that Dispose will always be called, even when an exception is thrown, which prevents the possibility of leaving a connection open inadvertently. Inside the using block we create a SqlCommand object that references a stored procedure, fill its parameters, and execute it by using the DataAccess base class' ExecuteReader method. The resulting SqlDataReader is passed to the ArticlesProvider base class' GetArticleCollectionFromReader method implemented earlier, so that the records read by the DataReader are consumed to create a list of ArticleDetails to return to the caller; you pass false as second parameters, so that the article's body is not read.
Important |
Remember to explicitly set the command's CommandType property to CommandType.StoredProcedure when you execute a stored procedure. If you don't, the code will work anyway, but the command text will first be interpreted as SQL text, that will fail, and then it will be re-executed as a stored procedure name. With the explicit setting, you avoid a wasted attempt to run it as a SQL statement, and therefore the execution speed will be a bit faster. |
The method that returns a single ArticleDetails object is similar to the method returning a collection. The difference is that the DataReader returned by executing the command is passed to the base class' GetArticleFromReader method, instead of to GetArticleCollectionFromReader. It also moves the cursor ahead one position and confirms that the reader actually has a record; otherwise, it just returns null:
/// <summary> /// Retrieves the article with the specified ID /// </summary> public override ArticleDetails GetArticleByID(int articleID) { using (SqlConnection cn = new SqlConnection(this.ConnectionString)) { SqlCommand cmd = new SqlCommand("tbh_Articles_GetArticleByID", cn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.Add("@ArticleID", SqlDbType.Int).Value = articleID; cn.Open(); IDataReader reader = ExecuteReader(cmd, CommandBehavior.SingleRow); if (reader.Read()) return GetArticleFromReader(reader, true); else return null; } }
Methods that retrieve and return a single field have a similar structure, but use ExecuteScalar instead of ExecuteReader, and cast the returned object to the expected type. For example, here's how to execute the tbh_Articles_GetArticleCount stored procedure that returns an integer, and tbh_Articles_GetArticleBody that returns a string:
/// <summary> /// Returns the total number of articles for the specified category /// </summary> public override int GetArticleCount(int categoryID) { using (SqlConnection cn = new SqlConnection(this.ConnectionString)) { SqlCommand cmd = new SqlCommand( "tbh_Articles_GetArticleCountByCategory", cn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.Add("@CategoryID", SqlDbType.Int).Value = categoryID; cn.Open(); return (int)ExecuteScalar(cmd); } } /// <summary> /// Retrieves the body for the article with the specified ID /// </summary> public override string GetArticleBody(int articleID) { using (SqlConnection cn = new SqlConnection(this.ConnectionString)) { SqlCommand cmd = new SqlCommand("tbh_Articles_GetArticleBody", cn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.Add("@ArticleID", SqlDbType.Int).Value = articleID; cn.Open(); return (string)ExecuteScalar(cmd); } }
Methods that delete or update a record return a Boolean value indicating whether at least one record was actually affected by the operation. To do that, they check the value returned by the ExecuteNonQuery method. Here are a couple of examples, UpdateArticle and DeleteArticle, but similar code would be used for methods such as RateArticle, ApproveArticle, IncrementArticleViewCount, and others:
/// <summary> /// Updates an article /// </summary> public override bool UpdateArticle(ArticleDetails article) { using (SqlConnection cn = new SqlConnection(this.ConnectionString)) { SqlCommand cmd = new SqlCommand("tbh_Articles_UpdateArticle", cn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.Add("@ArticleID", SqlDbType.Int).Value = article.ID; cmd.Parameters.Add("@CategoryID", SqlDbType.Int).Value = article.CategoryID; cmd.Parameters.Add("@Title", SqlDbType.NVarChar).Value = article.Title; cmd.Parameters.Add("@Abstract", SqlDbType.NVarChar).Value = article.Abstract; cmd.Parameters.Add("@Body", SqlDbType.NVarChar).Value = article.Body; cmd.Parameters.Add("@Country", SqlDbType.NVarChar).Value = article.Country; cmd.Parameters.Add("@State", SqlDbType.NVarChar).Value = article.State; cmd.Parameters.Add("@City", SqlDbType.NVarChar).Value = article.City; cmd.Parameters.Add("@ReleaseDate", SqlDbType.DateTime).Value = article.ReleaseDate; cmd.Parameters.Add("@ExpireDate", SqlDbType.DateTime).Value = article.ExpireDate; cmd.Parameters.Add("@Approved", SqlDbType.Bit).Value = article.Approved; cmd.Parameters.Add("@Listed", SqlDbType.Bit).Value = article.Listed; cmd.Parameters.Add("@CommentsEnabled", SqlDbType.Bit).Value = article.CommentsEnabled; cmd.Parameters.Add("@OnlyForMembers", SqlDbType.Bit).Value = article.OnlyForMembers; cn.Open(); int ret = ExecuteNonQuery(cmd); return (ret == 1); } } /// <summary> /// Deletes an article /// </summary> public override bool DeleteArticle(int articleID) { using (SqlConnection cn = new SqlConnection(this.ConnectionString)) { SqlCommand cmd = new SqlCommand("tbh_Articles_DeleteArticle", cn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.Add("@ArticleID", SqlDbType.Int).Value = articleID; cn.Open(); int ret = ExecuteNonQuery(cmd); return (ret == 1); } }
Finally, methods that insert a new record into the DB return the ID that was automatically created on the database server and returned by the stored procedure as an output parameter:
/// <summary> /// Inserts a new article /// </summary> public override int InsertArticle(ArticleDetails article) { using (SqlConnection cn = new SqlConnection(this.ConnectionString)) { SqlCommand cmd = new SqlCommand("tbh_Articles_InsertArticle", cn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.Add("@AddedDate", SqlDbType.DateTime).Value = article.AddedDate; cmd.Parameters.Add("@AddedBy", SqlDbType.NVarChar).Value = article.AddedBy; cmd.Parameters.Add("@CategoryID", SqlDbType.Int).Value = article.CategoryID; cmd.Parameters.Add("@Title", SqlDbType.NVarChar).Value = article.Title; cmd.Parameters.Add("@Abstract", SqlDbType.NVarChar).Value = article.Abstract; cmd.Parameters.Add("@Body", SqlDbType.NVarChar).Value = article.Body; cmd.Parameters.Add("@Country", SqlDbType.NVarChar).Value = article.Country; cmd.Parameters.Add("@State", SqlDbType.NVarChar).Value = article.State; cmd.Parameters.Add("@City", SqlDbType.NVarChar).Value = article.City; cmd.Parameters.Add("@ReleaseDate", SqlDbType.DateTime).Value = article.ReleaseDate; cmd.Parameters.Add("@ExpireDate", SqlDbType.DateTime).Value = article.ExpireDate; cmd.Parameters.Add("@Approved", SqlDbType.Bit).Value = article.Approved; cmd.Parameters.Add("@Listed", SqlDbType.Bit).Value = article.Listed; cmd.Parameters.Add("@CommentsEnabled", SqlDbType.Bit).Value = article.CommentsEnabled; cmd.Parameters.Add("@OnlyForMembers", SqlDbType.Bit).Value = article.OnlyForMembers; cmd.Parameters.Add("@ArticleID", SqlDbType.Int).Direction = ParameterDirection.Output; cn.Open(); int ret = ExecuteNonQuery(cmd); return (int)cmd.Parameters["@ArticleID"].Value; } } // other methods here... } }
To get a reference to the Articles provider indicated in the web.config file, you should specify ArticlesProvider.Instance. This is fine for one provider, but when you have other providers it would be better to have all of them grouped under a single "entry point." For this reason I've added a simple static helper class, implemented in ~/App_Code/DAL/SiteProvider.cs and called SiteProvider, which exposes static methods to easily see and reference all current providers. Here's the code for this class:
namespace MB.TheBeerHouse.DAL { public static class SiteProvider { public static ArticlesProvider Articles { get { return ArticlesProvider.Instance; } } } }
It will be extended in subsequent chapters to support other providers, so that you'll be able to write SiteProvider.Articles.{MethodName}, SiteProvider.Polls.{MethodName}, and so on.
As we did for the data access classes, the business classes are created directly under the ~/App_Code folder, in a BLL subfolder, so that they are automatically compiled at runtime, just like the pages. Business classes use the DAL classes to provide access to data and are mostly used to enforce validation rules, check constraints, and provide an object-oriented representation of the data and methods to work with it. Thus, the BLL serves as a mapping layer that makes the underlying relational database appear as objects to user interface code. Relational databases are inherently not object oriented, so this BLL provides a far more useful representation of data. Later you'll use the new ObjectDataSource to bind data from BLL classes to some template UI controls, such as the GridView and the DataList. This section presents the Article business class and describes some of the unique aspects of the other business classes.
The first business class we'll implement is BaseArticle, which is used as the base class for the Article, Category, and Comment classes. It descends from the BizObject class developed in Chapter 3, adding some article-specific properties. It starts by defining three properties, ID, AddedDate, and AddedBy, that are common to all business classes in the articles module:
namespace MB.TheBeerHouse.BLL.Articles { public abstract class BaseArticle : BizObject { private int _id = 0; public int ID { get { return _id; } protected set { _id = value; } } private DateTime _addedDate = DateTime.Now; public DateTime AddedDate { get { return _addedDate; } protected set { _addedDate = value; } } private string _addedBy = ""; public string AddedBy { get { return _addedBy; } protected set { _addedBy = value; } }
It then defines a Settings property that returns an instance of the ArticlesElement configuration class:
protected static ArticlesElement Settings { get { return Globals.Settings.Articles; } }
Finally, it has a CacheData method that takes a key and a value, and if the value is not null it creates a new entry in the Cache object returned by the base class:
protected static void CacheData(string key, object data) { if (Settings.EnableCaching && data != null) { BizObject.Cache.Insert(key, data, null, DateTime.Now.AddSeconds(Settings.CacheDuration), TimeSpan.Zero); } } } }
The CacheData method is located here instead of in the BizObject base class because it will cache the data only if caching is enabled, which is a module-specific setting (the forums module will have the EnableCaching setting as well, but it may have a different value).
This class is implemented in the ~/App_Code/BLL/Articles/Article.cs file. It starts with the declaration of the instance properties that wrap all data read from a record of the tbh_Articles table. The code that follows shows some of these properties (not all because they are very similar, and in most cases they just wrap a private field) and the constructor that initializes them:
namespace MB.TheBeerHouse.BLL.Articles { public class Article : BaseArticle { public Article(int id, DateTime addedDate, string addedBy, int categoryID, string categoryTitle, string title, string artabstract, string body, string country, string state, string city, DateTime releaseDate, DateTime expireDate, bool approved, bool listed, bool commentsEnabled, bool onlyForMembers, int viewCount, int votes, int totalRating) { this.ID = id; this.AddedDate = addedDate; this.AddedBy = addedBy; this.CategoryID = categoryID; this.CategoryTitle = categoryTitle; this.Title = title; this.Abstract = artabstract; this.Body = body; this.Country = country; this.State = state; this.City = city; this.ReleaseDate = releaseDate; this.ExpireDate = expireDate; this.Approved = approved; this.Listed = listed; this.CommentsEnabled = commentsEnabled; this.OnlyForMembers = onlyForMembers; this.ViewCount = viewCount; this.Votes = votes; this.TotalRating = totalRating; } private int _categoryID = 0; public int CategoryID { get { return _categoryID; } set { _categoryID = value; } } private string _categoryTitle = ""; public string CategoryTitle { get { return _categoryTitle; } private set { _categoryTitle = value; } } private string _title = ""; public string Title { get { return _title; } set { _title = value; } } private string _abstract = ""; public string Abstract { get { return _abstract; } set { _abstract = value; } } private string _body = null; public string Body { get { if (_body == null) _body = SiteProvider.Articles.GetArticleBody(this.ID); return _body; } set { _body = value; } } private DateTime _releaseDate = DateTime.Now; public DateTime ReleaseDate { get { return _releaseDate; } set { _releaseDate = value; } } private int _votes = 0; public int Votes { get { return _votes; } private set { _votes = value; } } private int _totalRating = 0; public int TotalRating { get { return _totalRating; } private set { _totalRating = value; } }
The Body property is interesting because it implements the lazy load pattern discussed earlier in this chapter. The Body field is retrieved by the getter function when the value of the Body property is requested by another class. Therefore, if the Body property is not accessed, this data will not be read from the database. Once it is requested and fetched, it will be held in memory in case it's requested again. If the private _body field is null it means that it wasn't loaded yet, so it's fetched by means of the DAL's GetArticleBody method and saved for possible use later. Thus, this Body property is providing lazy load and caching functionality, each of which enhance performance.
There are also a few calculated and read-only properties. The Location property returns a string with the full location of an event described in the article, consisting of the city, state/province, and country. Remember that the state and city fields could include more names separated by a semicolon (typically variations and abbreviations of the state name, such as "New York", "NY", "NewYork", and so on). For this reason the fields are split, and the first token is used. Here's the complete code:
public string Location { get { string location = this.City.Split(‘;')[0]; if (this.State.Length > 0) { if (location.Length > 0) location += ", "; location += this.State.Split(‘;')[0]; } if (this.Country.Length > 0) { if (location.Length > 0) location += ", "; location += this.Country; } return location; } }
The AverageRating calculated read-only property checks whether the total number of votes is 0; and the division is not done in that case to avoid a DivideByZeroException, and 0 is returned instead:
public double AverageRating { get { if (this.Votes >= 1) return ((double)this.TotalRating / (double)this.Votes); else return 0.0; } }
The other calculated read-only property is Published, which returns true if the article is approved and the current date is between the specified ReleaseDate and ExpireDate:
public bool Published { get { return (this.Approved && this.ReleaseDate <= DateTime.Now && this.ExpireDate > DateTime.Now); } }
Other properties are Category and Comments, which also use the lazy load pattern to return, respectively, a full Category object representing the article's parent category, and the article's comments:
private Category _category = null; public Category Category { get { if (_category == null) _category = Category.GetCategoryByID(this.CategoryID); return _category; } } private List<Comment> _comments = null; public List<Comment> Comments { get { if (_comments==null) _comments = Comment.GetComments(this.ID, 0, Article.MAXROWS); return _comments; } }
In addition to properties, the Article class also has a number of instance methods such as Delete, Rate, Approve, and so on, that delegate the work to the respective static methods (DeleteArticle, RateArticle, ApproveArticle, etc.) defined in the same class, which you'll see shortly. Here are a few examples:
public bool Delete() { bool success = Article.DeleteArticle(this.ID); if (success) this.ID = 0; return success; } public bool Update() { return Article.UpdateArticle(this.ID, this.CategoryID, this.Title, this.Abstract, this.Body, this.Country, this.State, this.City, this.ReleaseDate, this.ExpireDate, this.Approved, this.Listed, this.CommentsEnabled, this.OnlyForMembers); } public bool Approve() { bool ret = Article.ApproveArticle(this.ID); if (success) this.Approved = true; return ret; } public bool IncrementViewCount() { return Article.IncrementArticleViewCount(this.ID); } public bool Rate(int rating) { return Article.RateArticle(this.ID, rating); }
The rest of the code contains the static methods that use the DAL to retrieve, create, update, delete, rate, and approve an article. Let's first review a couple of overloads for the GetArticles method: one returns all articles for the specified category, and the other returns a page of articles for the specified category:
public static List<Article> GetArticles(int categoryID) { return GetArticles(categoryID, 0, Article.MAXROWS); } public static List<Article> GetArticles(int categoryID, int startRowIndex, int maximumRows) { if (categoryID <= 0) return GetArticles(startRowIndex, maximumRows); List<Article> articles = null; string key = "Articles_Articles_" + categoryID.ToString() + "_" + startRowIndex.ToString() + "_" + maximumRows.ToString(); if (BaseArticle.Settings.EnableCaching && BizObject.Cache[key] != null) { articles = (List<Article>)BizObject.Cache[key]; } else { List<ArticleDetails> recordset = SiteProvider.Articles.GetArticles( categoryID, GetPageIndex(startRowIndex, maximumRows), maximumRows); articles = GetArticleListFromArticleDetailsList(recordset); BaseArticle.CacheData(key, articles); } return articles; }
The first version just forwards the call to the second version, passing 0 as the start index (business objects used by the ObjectDataSource are required to accept the start row index to support pagination, not the page index as the DAL and the stored procedures do), and a very large number (the maximum integer) as the page size. The second version actually contains the logic: If the input ID of the parent category is less than zero, then the call is forwarded to yet another version that takes no category ID and returns articles of any category. If caching is enabled and the pages of the article you are requesting are already in the cache, they are retrieved from there and returned. Otherwise, they are retrieved from the DB by means of a call to the DAL's GetArticles method, converted to a list of Article objects (the DAL method returns a list of ArticleDetails object), cached, and finally returned to the caller. Note that because the DAL method expects the page index, and not the index of the first record to retrieve, the BLL method's parameters are passed to the BizObject base class' GetPageIndex helper method for the conversion. Also note how the key for the cache entry is built: It is the sum of the module name (Articles), what you're going to retrieve (Articles), and all the input parameters — all joined with an underscore character. For example, the result may be something like Articles_Articles_4_30_10 (4 is the category ID, 30 is the starting row index, and 10 is the maximum rows, aka page size).
The conversion from a list of ArticleDetails to a list of Article objects is performed by the static private GetArticleListFromArticleDetailsList method, which in turn calls GetArticleFromArticleDetails for each object of the input List:
private static List<Article> GetArticleListFromArticleDetailsList( List<ArticleDetails> recordset) { List<Article> articles = new List<Article>(); foreach (ArticleDetails record in recordset) articles.Add(GetArticleFromArticleDetails(record)); return articles; } private static Article GetArticleFromArticleDetails(ArticleDetails record) { if (record == null) return null; else { return new Article(record.ID, record.AddedDate, record.AddedBy, record.CategoryID, record.CategoryTitle, record.Title, record.Abstract, record.Body, record.Country, record.State, record.City, record.ReleaseDate, record.ExpireDate, record.Approved, record.Listed, record.CommentsEnabled, record.OnlyForMembers, record.ViewCount, record.Votes, record.TotalRating); } }
The GetArticleCount method returns the number of articles for a specific collection. Its structure is very similar to what you saw for GetArticles, including the use of caching:
public static int GetArticleCount(int categoryID) { if (categoryID <= 0) return GetArticleCount(); int articleCount = 0; string key = "Articles_ArticleCount_" + categoryID.ToString(); if (BaseArticle.Settings.EnableCaching && BizObject.Cache[key] != null) { articleCount = (int)BizObject.Cache[key]; } else { articleCount = SiteProvider.Articles.GetArticleCount(categoryID); BaseArticle.CacheData(key, articleCount); } return articleCount; }
GetArticleByID is also very similar, but instead of calling GetArticleListFromarticleDetailsList, this time it calls GetArticleFromArticleDetails directly because there's a single object to convert:
public static Article GetArticleByID(int articleID) { Article article = null; string key = "Articles_Article_" + articleID.ToString(); if (BaseArticle.Settings.EnableCaching && BizObject.Cache[key] != null) { article = (Article)BizObject.Cache[key]; } else { article = GetArticleFromArticleDetails( SiteProvider.Articles.GetArticleByID(articleID)); BaseArticle.CacheData(key, article); } return article; }
The InsertArticle method doesn't use caching because it doesn't retrieve and return data, but it has other peculiarities. First of all, it checks whether the current user belongs to the Administrators or Editors role, and if not it sets the Approved field to false. Then it checks whether the releaseDate and expireDate parameters are equal to the DateTime.MinValue, which would be the case if the respective textboxes in the administration user interface were left blank; in that case ReleaseDate is set to the current date, and ExpireDate is set to the DateTime's maximum value, so that in practice the article never expires (which is what the administrator intended when he left those fields blank). It then runs the DAL's InsertArticle method, and finally purges the Articles data from the cache, so that the next call to GetArticles will run a new query to fetch the record from the database. Here's the complete code:
public static int InsertArticle(int categoryID, string title, string Abstract, string body, string country, string state, string city, DateTime releaseDate, DateTime expireDate, bool approved, bool listed, bool commentsEnabled, bool onlyForMembers) { // ensure that the "approved" option is false if the current user is not // an administrator or a editor (it may be a contributor for example) bool canApprove = (Article.CurrentUser.IsInRole("Administrators") || Article.CurrentUser.IsInRole("Editors")); if (!canApprove) approved = false; title = BizObject.ConvertNullToEmptyString(title); Abstract = BizObject.ConvertNullToEmptyString(Abstract); body = BizObject.ConvertNullToEmptyString(body); country = BizObject.ConvertNullToEmptyString(country); state = BizObject.ConvertNullToEmptyString(state); city = BizObject.ConvertNullToEmptyString(city); if (releaseDate == DateTime.MinValue) releaseDate = DateTime.Now; if (expireDate == DateTime.MinValue) expireDate = DateTime.MaxValue; ArticleDetails record = new ArticleDetails(0, DateTime.Now, Article.CurrentUserName, categoryID, "", title, Abstract, body, country, state, city, releaseDate, expireDate, approved, listed, commentsEnabled, onlyForMembers, 0, 0, 0); int ret = SiteProvider.Articles.InsertArticle(record); BizObject.PurgeCacheItems("articles_article"); return ret; }
Note that for string parameters the BizObject's ConvertNullToEmptyString method is called, to ensure that they are converted to an empty string if they are null (otherwise the DAL method would fail). This could be done later from the user interface, by setting the ConvertEmptyStringToNull property of the ObjectDataSource's insert parameter to true, so that an empty textbox will be read as an empty string and not null, as it is by default. However, I consider this to be more of a business rule, and I prefer to centralize it once in the business layer, instead of making sure that I set that parameter's property every time I use an ObjectDataSource.
The UpdateArticle method is similar, the only difference being that the DAL's UpdateArticle method is called instead of the InsertArticle method:
public static bool UpdateArticle(int id, int categoryID, string title, string Abstract, string body, string country, string state, string city, DateTime releaseDate, DateTime expireDate, bool approved, bool listed, bool commentsEnabled, bool onlyForMembers) { title = BizObject.ConvertNullToEmptyString(title); Abstract = BizObject.ConvertNullToEmptyString(Abstract); body = BizObject.ConvertNullToEmptyString(body); country = BizObject.ConvertNullToEmptyString(country); state = BizObject.ConvertNullToEmptyString(state); city = BizObject.ConvertNullToEmptyString(city); if (releaseDate == DateTime.MinValue) releaseDate = DateTime.Now; if (expireDate == DateTime.MinValue) expireDate = DateTime.MaxValue; ArticleDetails record = new ArticleDetails(id, DateTime.Now, "", categoryID, "", title, Abstract, body, country, state, city, releaseDate, expireDate, approved, listed, commentsEnabled, onlyForMembers, 0, 0, 0); bool ret = SiteProvider.Articles.UpdateArticle(record); BizObject.PurgeCacheItems("articles_article_" + id.ToString()); BizObject.PurgeCacheItems("articles_articles"); return ret; }
Other methods that update data in the tbh_Articles table are even simpler, as they just call their respective method in the DAL, and purge the current data from the cache. The cache is cleared to force a fetch of the newly updated data the next time it's requested. DeleteArticle and ApproveArticle do just that:
public static bool DeleteArticle(int id) { bool ret = SiteProvider.Articles.DeleteArticle(id); new RecordDeletedEvent("article", id, null).Raise(); BizObject.PurgeCacheItems("articles_article"); return ret; } public static bool ApproveArticle(int id) { bool ret = SiteProvider.Articles.ApproveArticle(id); BizObject.PurgeCacheItems("articles_article_" + id.ToString()); BizObject.PurgeCacheItems("articles_articles"); return ret; }
Methods such as IncrementArticleViewCount and RateArticle call the DAL method to process the update, except that they don't clear the cache. It's only necessary to clear the cache when the underlying data held by the cache has changed:
public static bool IncrementArticleViewCount(int id) { return SiteProvider.Articles.IncrementArticleViewCount(id); } public static bool RateArticle(int id, int rating) { return SiteProvider.Articles.RateArticle(id, rating); } // other static methods... } }
The Category class (implemented in the file ~/App_Code/BLL/Articles/Category.cs) is not much different from the Article class, but it's shorter and simpler because it doesn't have as many wrapper properties and methods, no support for pagination (and thus fewer overloads), and nothing other than the basic CRUD methods. The most interesting properties are AllArticles and PublishedArticles, both of which use the lazy load pattern to retrieve the list of child articles. They use the Article's GetArticles static method, but they each use a different overload of it. PublishedArticles uses the overload that takes in a Boolean value indicating that you want to retrieve only published articles, and passes true:
private List<Article> _allArticles = null; public List<Article> AllArticles { get { if (_allArticles == null) _allArticles = Article.GetArticles(this.ID, 0, Category.MAXROWS); return _allArticles; } } private List<Article> _publishedArticles = null; public List<Article> PublishedArticles { get { if (_publishedArticles == null) _publishedArticles = Article.GetArticles(true, this.ID, 0, Category.MAXROWS); return _publishedArticles; } }
The Comment class (implemented in the file ~/App_Code/BLL/Articles/Comment.cs) is quite simple so I won't show it here in its entirety. As for the properties, the only notable one is the calculated EncodedBody property, which encodes the HTML text returned by the plain Body property by means of the BizObject base class' EncodeText static method. Here they are:
private string _body = ""; public string Body { get { return _body; } set { _body = value; } } public string EncodedBody { get { return BizObject.EncodeText(this.Body); } }
One of the overloaded GetComments methods that's called from the administration section returns the comments sorted from the newest to the oldest, and supports pagination. The UI code that lists comments below a particular article calls another GetComments overload that retrieves all comments for a specific article, and with no pagination support, and in that case they are sorted from oldest to newest. Let's look at the code for a couple of overloads that sort items from the newest to the oldest:
public static List<Comment> GetComments(int startRowIndex, int maximumRows) { List<Comment> comments = null; string key = "Articles_Comments_" + startRowIndex.ToString() + "_" + maximumRows.ToString(); if (BaseArticle.Settings.EnableCaching && BizObject.Cache[key] != null) { comments = (List<Comment>)BizObject.Cache[key]; } else { List<CommentDetails> recordset = SiteProvider.Articles.GetComments( GetPageIndex(startRowIndex, maximumRows), maximumRows); comments = GetCommentListFromCommentDetailsList(recordset); BaseArticle.CacheData(key, comments); } return comments; } public static List<Comment> GetComments(int articleID, int startRowIndex, int maximumRows) { List<Comment> comments = null; string key = "Articles_Comments_" + articleID.ToString() + "_" + startRowIndex.ToString() + "_" + maximumRows.ToString(); if (BaseArticle.Settings.EnableCaching && BizObject.Cache[key] != null) { comments = (List<Comment>)BizObject.Cache[key]; } else { List<CommentDetails> recordset = SiteProvider.Articles.GetComments(articleID, GetPageIndex(startRowIndex, maximumRows), maximumRows); comments = GetCommentListFromCommentDetailsList(recordset); BaseArticle.CacheData(key, comments); } return comments; }
The final GetComments overload first retrieves the records from the second overload just shown, with 0 as start row index, and the maximum integer as page size, so that it retrieves all comments for an article. Next, it uses the CommentComparer class (that you'll see in a moment) to invert the order. Here's its code:
public static List<Comment> GetComments(int articleID) { List<Comment> comments = GetComments(articleID, 0, Comment.MAXROWS); comments.Sort(new CommentComparer("AddedDate ASC")); return comments; }
The CommentComparer class takes the sort clause in the constructor method, and uses it later to determine which field it must compare two comments against. The code in the constructor method also checks whether the sort string ends with "DESC" and if so sets a _reverse field to true. Then, the Compare method compares two comments by delegating the comparison logic to DateTime's or String's Compare method (according to whether the comparison is being made against the AddedDate or AddedBy field), and if the _reverse field is true it inverts the result. The Equals method returns true if the two comments have the same ID. Here's the complete code:
public class CommentComparer : IComparer<Comment> { private string _sortBy; private bool _reverse; public CommentComparer(string sortBy) { if (!string.IsNullOrEmpty(sortBy)) { sortBy = sortBy.ToLower(); _reverse = sortBy.EndsWith("desc"); _sortBy = sortBy.Replace("desc", "").Replace("asc", ""); } } public int Compare(Comment x, Comment y) { int ret = 0; switch (_sortBy) { case "addeddate": ret = DateTime.Compare(x.AddedDate, y.AddedDate); break; case "addedby": ret = string.Compare(x.AddedBy, y.AddedBy, StringComparison.InvariantCultureIgnoreCase); break; } return (ret * (_reverse ? -1 : 1)); } public bool Equals(Comment x, Comment y) { return (x.ID == y.ID); } }
The database design and the data access classes for the articles module are now complete, so it's time to code the user interface. We will use the business classes to retrieve and manage Article data from the DB. We'll start by developing the administration console, so that we can use it later to add and manage sample records when we code and test the UI for end users.
This page, located under the ~/Admin folder, allows the administrator and editors to add, delete, and edit article categories, as well as directly jump to the list of articles for a specific category. The screenshot of the page, shown in Figure 5-6, demonstrates what I'm talking about, and then you'll learn how to build it.
There's a GridView control that displays all the categories from the database (with the title, the description, and the graphical icon). Moreover, the icons on the very far right of the grid are, respectively, a HyperLink to the ManageArticles.aspx page, which lists the child articles of that category; a LinkButton to delete the category; and another one to edit it. When the pencil icon is clicked, the grid is not turned into edit mode as it was in the first edition of the book, but instead, the record is edited through the DetailsView box at the bottom of the page. This makes the page cleaner, and it doesn't mess with the layout if you need to edit more fields than those shown in Read mode. That is actually the case in this page, because the Importance field is not shown in the grid, but you can still edit it in the DetailsView.
The DetailsView is also used to show additional fields, even if in read-only mode, such as ID, AddedDate, and AddedBy. Finally, it is also used to insert a new category — the insert mode is the default in this case, so it is used when the page loads, or if you click the Cancel command while you're editing a current category. At the very bottom of the page is a control used to upload a file, typically an image used to graphically represent a category.
Now we'll examine the page's source code piece-by-piece. The first step is to create the ObjectDataSource that will be the data source for the GridView control that displays the categories. You only need to define the Select and Delete methods, because the updates and the inserts will be done by the DetailsView control, which has a different ObjectDataSource:
<asp:ObjectDataSource ID="objAllCategories" runat="server" TypeName="MB.TheBeerHouse.BLL.Articles.Category" SelectMethod="GetCategories" DeleteMethod="DeleteCategory"> </asp:ObjectDataSource>
The declaration of the GridView is similar to what you might use for ASP.NET 1.1's DataGrid, in that it has a <Columns> section where you define its columns. In particular, it has the following fields:
An ImageField to show the image whose URL is returned by the Article's ImageUrl. This type of column is very convenient because you define everything in a single line. In ASP.NET 1.x you would have needed a TemplateColumn instead.
A TemplateField to define a template column that shows the category's title and description.
A HyperLinkField to create a link to ManageArticles.aspx, with the category's ID on the querystring, so that the page will retrieve it and display only that category's child articles.
A CommandField of type Select that will be used to edit the category. We use the Select command instead of the Edit command because an Edit command would put that GridView's row into edit mode, and that isn't what we want. Instead, we want to select (highlight) the row and have the DetailsView below enter the edit mode for the selected row.
A CommandField of type Delete.
Of course, there are also some other GridView properties to set, the most important being the DataSourceID that hooks up the ObjectDataSource to the grid, and DataKeyNames, to specify that the ID field is the key field. Here is the GridView's complete declaration:
<asp:GridView ID="gvwCategories" runat="server" AutoGenerateColumns="False" DataSourceID="objAllCategories" Width="100%" DataKeyNames="ID" OnRowDeleted="gvwCategories_RowDeleted" OnRowCreated="gvwCategories_RowCreated" OnSelectedIndexChanged="gvwCategories_SelectedIndexChanged" ShowHeader="false"> <Columns> <asp:ImageField DataImageUrlField="ImageUrl"> <ItemStyle Width="100px" /> </asp:ImageField> <asp:TemplateField> <ItemTemplate> <div class="sectionsubtitle"> <asp:Literal runat="server" ID="lblCatTitle" Text='<%# Eval("Title") %>' /> </div> <br /> <asp:Literal runat="server" ID="lblDescription" Text='<%# Eval("Description") %>' /> </ItemTemplate> </asp:TemplateField> <asp:HyperLinkField Text="<img border='0' src='../Images/ArrowR.gif' alt='View articles' />" DataNavigateUrlFormatString="ManageArticles.aspx?ID={0}" DataNavigateUrlFields="ID"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:HyperLinkField> <asp:CommandField ButtonType="Image" SelectImageUrl="~/Images/Edit.gif" SelectText="Update category" ShowSelectButton="True"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:CommandField> <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif" DeleteText="Delete category" ShowDeleteButton="True"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:CommandField> </Columns> </asp:GridView>
There's a second ObjectDataSource used to handle the data retrieval, inserts, and updates from the bottom DetailsView. All parameters for the insert and update are automatically inferred from the DetailsView's fields. The only specific parameter is the category ID for the GetCategoryByID select method, which is set to the GridView's selected value (i.e., the key of the selected row):
<asp:ObjectDataSource ID="objCurrCategory" runat="server" TypeName="MB.TheBeerHouse.BLL.Articles.Category" InsertMethod="InsertCategory" SelectMethod="GetCategoryByID" UpdateMethod="UpdateCategory"> <SelectParameters> <asp:ControlParameter ControlID="gvwCategories" Name="categoryID" PropertyName="SelectedValue" Type="Int32" /> </SelectParameters> </asp:ObjectDataSource>
The DetailsView control defines template fields for the Title, Importance, ImageUrl, and Description information, which are the fields that are not read-only. TemplateFields are usually better than an editable BoundFields because you typically need to validate input, and you can do that directly on the client side with some validators put into the TemplateField, which is better than doing the validation in the code-behind or in a business class:
<asp:DetailsView ID="dvwCategory" runat="server" AutoGenerateRows="False" DataSourceID="objCurrCategory" Height="50px" Width="50%" AutoGenerateEditButton="True" AutoGenerateInsertButton="True" HeaderText="Category Details" OnItemInserted="dvwCategory_ItemInserted" OnItemUpdated="dvwCategory_ItemUpdated" DataKeyNames="ID" OnItemCreated="dvwCategory_ItemCreated" DefaultMode="Insert" OnItemCommand="dvwCategory_ItemCommand"> <FieldHeaderStyle Width="100px" /> <Fields> <asp:BoundField DataField="ID" HeaderText="ID" ReadOnly="True" SortExpression="ID" InsertVisible="False" /> <asp:BoundField DataField="AddedDate" HeaderText="AddedDate" InsertVisible="False" ReadOnly="True" SortExpression="AddedDate" /> <asp:BoundField DataField="AddedBy" HeaderText="AddedBy" InsertVisible="False" ReadOnly="True" SortExpression="AddedBy" /> <asp:TemplateField HeaderText="Title" SortExpression="Title"> <ItemTemplate> <asp:Label ID="lblTitle" runat="server" Text='<%# Eval("Title") %>'></asp:Label> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtTitle" runat="server" Text='<%# Bind("Title") %>' MaxLength="256" Width="100%"></asp:TextBox> <asp:RequiredFieldValidator ID="valRequireTitle" runat="server" ControlToValidate="txtTitle" SetFocusOnError="true" Text="The Title field is required." ToolTip="The Title field is required." Display="Dynamic" /> </EditItemTemplate> </asp:TemplateField> <asp:TemplateField HeaderText="Importance" SortExpression="Importance"> <ItemTemplate> <asp:Label ID="lblImportance" runat="server" Text='<%# Eval("Importance") %>'></asp:Label> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtImportance" runat="server" Text='<%# Bind("Importance") %>' MaxLength="256" Width="100%"></asp:TextBox> <asp:RequiredFieldValidator ID="valRequireImportance" runat="server" ControlToValidate="txtImportance" SetFocusOnError="true" Text="The Importance field is required." ToolTip="The Importance field is required." Display="Dynamic" /> <asp:CompareValidator ID="valInportanceType" runat="server" Operator="DataTypeCheck" Type="Integer" ControlToValidate="txtImportance" Text="The Importance must be an integer." ToolTip="The Importance must be an integer." Display="dynamic" /> </EditItemTemplate> </asp:TemplateField> <asp:TemplateField HeaderText="Image" ConvertEmptyStringToNull="False"> <ItemTemplate> <asp:Image ID="imgImage" runat="server" ImageUrl='<%# Eval("ImageUrl") %>' AlternateText='<%# Eval("Title") %>' Visible='<%# !string.IsNullOrEmpty( DataBinder.Eval(Container.DataItem, "ImageUrl").ToString()) %>' /> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtImageUrl" runat="server" Text='<%# Bind("ImageUrl") %>' MaxLength="256" Width="100%" /> </EditItemTemplate> </asp:TemplateField> <asp:TemplateField HeaderText="Description" SortExpression="Description" ConvertEmptyStringToNull="False"> <ItemTemplate> <asp:Label ID="lblDescription" runat="server" Text='<%# Eval("Description") %>' Width="100%"></asp:Label> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtDescription" runat="server" Text='<%# Bind("Description") %>' Rows="5" TextMode="MultiLine" MaxLength="4000" Width="100%"></asp:TextBox> </EditItemTemplate> </asp:TemplateField> </Fields> </asp:DetailsView>
The page ends with the declaration of a control used to upload files on the server. This is done with a custom user control (which will be used in other pages as well) that will be covered soon.
In the code-behind file there's absolutely no code to retrieve, update, insert, or delete data, because that's all done by the two ObjectDataSource controls on the page. There are, however, some event handlers for the GridView and the DetailsView controls. Let's see what they do, event by event. First, you handle the grid's SelectedIndexChanged event to switch the DetailsView to edit mode, so it lets the user edit the grid's selected category:
public partial class ManageCategories : BasePage { protected void gvwCategories_SelectedIndexChanged(object sender, EventArgs e) { dvwCategory.ChangeMode(DetailsViewMode.Edit); }
Next it handles the grid's RowDeleted event, to deselect any row that may have been selected, and it rebinds the grid so that the deleted row is removed from the displayed grid, and then it switches the DetailsView's mode back to insert (its default mode):
protected void gvwCategories_RowDeleted(object sender, GridViewDeletedEventArgs e) { gvwCategories.SelectedIndex = -1; gvwCategories.DataBind(); dvwCategory.ChangeMode(DetailsViewMode.Insert); }
Deleting a category is a critical operation because it will also delete the child articles (because of the cascaded delete we set up in the tables using the database diagram). Therefore, we must minimize the opportunities for a user to accidentally delete a category by clicking on a link by mistake. To ensure that the user really does want to delete an article, we'll ask for confirmation when the link is clicked. To do this we'll handle the GridView's RowCreated event, and for each data row (i.e., rows that are not the header, footer, or pagination bar) we'll get a reference to the Delete ImageButton (the first and only control in the fifth column), and we'll insert a JavaScript Confirm dialog on its client-side onclick event. You've already done something similar for the User Management administration console developed in Chapter 2. Here's the event handler's code:
protected void gvwCategories_RowCreated(object sender, GridViewRowEventArgs e) { if (e.Row.RowType == DataControlRowType.DataRow) { ImageButton btn = e.Row.Cells[4].Controls[0] as ImageButton; btn.OnClientClick = " if (confirm(‘Are you sure you want to delete this category?') == false) return false; "; } }
As for the DetailsView's events, we'll intercept the creation and update of a record, and the cancel command, so that we can deselect any GridView row that may be currently selected, and rebind it to its data source to display the updated data:
protected void dvwCategory_ItemInserted(object sender, DetailsViewInsertedEventArgs e) { gvwCategories.SelectedIndex = -1; gvwCategories.DataBind(); } protected void dvwCategory_ItemUpdated(object sender, DetailsViewUpdatedEventArgs e) { gvwCategories.SelectedIndex = -1; gvwCategories.DataBind(); } protected void dvwCategory_ItemCommand(object sender, DetailsViewCommandEventArgs e) { if (e.CommandName == "Cancel") { gvwCategories.SelectedIndex = -1; gvwCategories.DataBind(); } }
Finally, we'll handle the control's ItemCreated event and, if the control is in insert mode, get a reference to the textbox for the Importance field, setting its default value to 0. Unfortunately, this is something you can't do with declarative properties, even though it's often necessary. Here's the workaround:
protected void dvwCategory_ItemCreated(object sender, EventArgs e) { if (dvwCategory.CurrentMode == DetailsViewMode.Insert) { TextBox txtImportance = (TextBox)dvwCategory.FindControl("txtImportance"); txtImportance.Text = "0"; } } }
This control, located under the ~/Controls folder, allows administrators and editors to upload a file (normally an image file) to the server and save it into their own private user-specific folder. Once the file is saved, the control displays the URL so that the editor can easily copy and paste it into the ImageUrl field for a property, or reference the image file in the article's WYSIWYG editor. The markup code is simple — it just declares an instance of the FileUpload control, a Submit button, and a couple of Labels for the positive or negative feedback:
Upload a file: <asp:FileUpload ID="filUpload" runat="server" /> <asp:Button ID="btnUpload" runat="server" OnClick="btnUpload_Click" Text="Upload" CausesValidation="false" /><br /> <asp:Label ID="lblFeedbackOK" SkinID="FeedbackOK" runat="server"></asp:Label> <asp:Label ID="lblFeedbackKO" SkinID="FeedbackKO" runat="server"></asp:Label>
The file is saved in the code-behind's btnUpload_Click event handler, into a user-specific folder under the ~/Uploads folder. The actual saving is done by calling the SaveAs method of the FileUpload's PostedFile object property. If the folder doesn't already exist, it is created by means of the System.IO .Directory.CreateDirectory static method:
protected void btnUpload_Click(object sender, EventArgs e) { if (filUpload.PostedFile != null && filUpload.PostedFile.ContentLength > 0) { try { // if not already present, create a directory // named /Uploads/{CurrentUserName} string dirUrl = (this.Page as MB.TheBeerHouse.UI.BasePage).BaseUrl + "Uploads/" + this.Page.User.Identity.Name; string dirPath = Server.MapPath(dirUrl); if (!Directory.Exists(dirPath)) Directory.CreateDirectory(dirPath); // save the file under the user's personal folder string fileUrl = dirUrl + "/" + Path.GetFileName(filUpload.PostedFile.FileName); filUpload.PostedFile.SaveAs(Server.MapPath(fileUrl)); lblFeedbackOK.Visible = true; lblFeedbackOK.Text = "File successfully uploaded: "+ fileUrl; } catch (Exception ex) { lblFeedbackKO.Visible = true; lblFeedbackKO.Text = ex.Message; } } }
This control can only be used by editors and administrators, so when the control loads we need to determine which user is online and throw a SecurityException if the user isn't supposed to see this control:
protected void Page_Load(object sender, EventArgs e) { // this control can only work for authenticated users if (!this.Page.User.Identity.IsAuthenticated) throw new SecurityException("Anonymous users cannot upload files."); lblFeedbackKO.Visible = false; lblFeedbackOK.Visible = false; }
To register this control on a page, you write the following directive at the top of the page (for example, the ManageCategories.aspx page):
<%@ Register Src="~/Controls/FileUploader.ascx" TagName="FileUploader" TagPrefix="mb" %>
And use this tag to create an instance of the control:
<mb:FileUploader ID="FileUploader1" runat="server" />
As mentioned before, the code that lists articles in the administrative ManageArticles.aspx page, and the end-user BrowseArticles.aspx page, is located not in the pages themselves but in a separate user control called ~/Controls/ArticleListing.ascx. This control displays a paginable list of articles for all categories or for a selected category, allows the user to change the page size, and can highlight articles referring to events that happen in the user's country, state, or city (if that information is present in the user's profile). In Figure 5-7, you can see what the control will look like once it's plugged into the ManageArticles.aspx page. Note that because the current user is an editor, each article row has buttons to edit or delete it (the pencil and trashcan icons). These won't be displayed if the control is put into an end-user page and the current user is not an editor or administrator.
The control starts with the declaration of an ObjectDataSource that uses the Category's GetCategory method to retrieve the data, consumed by a DropDownList that serves as category picker to filter the articles:
<asp:ObjectDataSource ID="objAllCategories" runat="server" TypeName="MB.TheBeerHouse.BLL.Articles.Category" SelectMethod="GetCategories"></asp:ObjectDataSource> <asp:Literal runat="server" ID="lblCategoryPicker"> Filter by category:</asp:Literal> <asp:DropDownList ID="ddlCategories" runat="server" AutoPostBack="True" DataSourceID="objAllCategories" DataTextField="Title" DataValueField="ID" AppendDataBoundItems="true" OnSelectedIndexChanged="ddlCategories_SelectedIndexChanged"> <asp:ListItem Value="0">All categories</asp:ListItem> </asp:DropDownList>
Note that an "All categories" ListItem is appended to those categories that were populated via databinding, so that the user can choose to have the articles of all categories displayed. To make this work, you also need to set the DropDownList's AppendDataBoundItems to true; otherwise, the binding will first clear the DropDownList and then add the items. There's another DropDownList that lets the user select the page size from a preconfigured list of values:
<asp:Literal runat="server" ID="lblPageSizePicker">Articles per page:</asp:Literal> <asp:DropDownList ID="ddlArticlesPerPage" runat="server" AutoPostBack="True" OnSelectedIndexChanged="ddlArticlesPerPage_SelectedIndexChanged"> <asp:ListItem Value="5">5</asp:ListItem> <asp:ListItem Value="10" Selected="True">10</asp:ListItem> <asp:ListItem Value="25">25</asp:ListItem> <asp:ListItem Value="50">50</asp:ListItem> <asp:ListItem Value="100">100</asp:ListItem> </asp:DropDownList>
As you may remember from the "Design" section, the <articles> configuration element has a pageSize attribute that will be used as default for this PageSize DropDownList. If the value saved in the config file is not already present in the list, it will be appended and selected dynamically. This is handled in the code-behind file.
The remainder of the page contains a GridView for listing the articles. It needs an ObjectDataSource that uses the Article class as a business object, and its GetArticles and DeleteArticle methods are used to select and delete articles. Because it uses pagination, you must also specify the method that returns the total number of articles: GetArticleCount. The overload of GetArticles used in this situation is the one that takes a Boolean value indicating whether you want all the articles or only published ones, the ID of the parent category, the index of the first row to retrieve, and the page size. You don't have to specify the last two parameters explicitly — they are implicitly added and their value filled in when the control has the EnablePaging property set to true. The ID of the parent category is set to the selected value of the ddlCategories DropDownList. Finally, the publishedOnly parameter is set to true by default, but this can be changed from the code-behind according to a custom property added to the user control, which you'll see shortly. This is the code for the ObjectDataSource:
<asp:ObjectDataSource ID="objArticles" runat="server" TypeName="MB.TheBeerHouse.BLL.Articles.Article" DeleteMethod="DeleteArticle" SelectMethod="GetArticles" SelectCountMethod="GetArticleCount" EnablePaging="True" > <DeleteParameters> <asp:Parameter Name="id" Type="Int32" /> </DeleteParameters> <SelectParameters> <asp:Parameter Name="publishedOnly" Type="Boolean" DefaultValue="true" /> <asp:ControlParameter ControlID="ddlCategories" Name="categoryID" PropertyName="SelectedValue" Type="Int32" /> </SelectParameters> </asp:ObjectDataSource>
Next we'll cover the GridView that displays articles. Generally, a GridView is used to display data with a tabular format. In this case, however, we're building a single-column grid of type TemplateField, which shows the article's title, abstract, location, author, release date, and view count each on a separate line. We couldn't fit all these items on separate columns of the same line because some of them are very wide (title and abstract), so why not just use a Repeater or a DataList in this case? Because the GridView has built-in support for pagination, whereas the other two databound controls do not. The template also includes a Panel with ImageButton controls to delete and approve the article, and a link to AddEditArticle.aspx to edit the article. The Panel's Visible property is bound to an expression that returns true (and thus makes the panel and its content visible) only if the current user is an administrator or an editor:
<asp:GridView SkinID="Articles" ID="gvwArticles" runat="server" AllowPaging="True" AutoGenerateColumns="False" DataKeyNames="ID" DataSourceID="objArticles" PageSize="10" ShowHeader="False" EmptyDataText="<b>There is no article to show for the selected category</b>" OnRowDataBound="gvwArticles_RowDataBound" OnRowCommand="gvwArticles_RowCommand"> <Columns> <asp:TemplateField HeaderText="Article List (including those not yet published)"> <HeaderStyle HorizontalAlign="Left" /> <ItemTemplate> <div class="articlebox"> <table cellpadding="0" cellspacing="0" style="width: 100%;"><tr><td> <div class="articletitle"> <asp:HyperLink runat="server" ID="lnkTitle" CssClass="articletitle" Text='<%# Eval("Title") %>' NavigateUrl='<%# "~/ShowArticle.aspx?ID="+ Eval("ID") %>'/> <asp:Image runat="server" ID="imgKey" ImageUrl="~/Images/key.gif" AlternateText="Requires login" Visible='<%# (bool)Eval("OnlyForMembers") && !Page.User.Identity.IsAuthenticated %>' /> <asp:Label runat="server" ID="lblNotApproved" Text="Not approved" SkinID="NotApproved" Visible='<%# !(bool)Eval("Approved") %>' /> </div> </td> <td style="text-align: right;"> <asp:Panel runat="server" ID="panEditArticle" Visible='<%# UserCanEdit %>'> <asp:ImageButton runat="server" ID="btnApprove" ImageUrl="~/Images/checkmark.gif" CommandName="Approve" CommandArgument='<%# Eval("ID") %>' AlternateText="Approve article" Visible='<%# !(bool)Eval("Approved") %>' OnClientClick="if (confirm(‘Are you sure you want to approve this article?') == false) return false; "/> <asp:HyperLink runat="server" ID="lnkEdit" ToolTip="Edit article" NavigateUrl='<%# "~/Admin/AddEditArticle.aspx?ID="+ Eval("ID") %>' ImageUrl="~/Images/Edit.gif" /> <asp:ImageButton runat="server" ID="btnDelete" ImageUrl="~/Images/Delete.gif" CommandName="Delete" AlternateText="Delete article" OnClientClick="if (confirm(‘Are you sure you want to delete this article?') == false) return false; "/> </asp:Panel> </td></tr></table> <b>Rating: </b> <asp:Literal runat="server" ID="lblRating" Text='<%# Eval("Votes") + "user(s) have rated this article "%>' /> <mb:RatingDisplay runat="server" ID="ratDisplay" Value='<%# Eval("AverageRating") %>' /> <br /> <b>Posted by: </b> <asp:Literal runat="server" ID="lblAddedBy" Text='<%# Eval("AddedBy") %>' />, on <asp:Literal runat="server" ID="lblAddedDate" Text='<%# Eval("ReleaseDate", "{0:d}") %>' />, in category " <asp:Literal runat="server" ID="lblCategory" Text='<%# Eval("CategoryTitle") %>' />" <br /> <b>Views: </b> <asp:Literal runat="server" ID="lblViews" Text='<%# "this article has been read "+ Eval("ViewCount") + "times" %>' /> <asp:Literal runat="server" ID="lblLocation" Visible='<%# Eval("Location").ToString().Length > 0 %>' Text='<%# "<br /><b>Location: </b>" + Eval("Location") %>' /> <br /> <div class="articleabstract"> <b>Abstract: </b> <asp:Literal runat="server" ID="lblAbstract" Text='<%# Eval("Abstract") %>' /> </div> </div> </ItemTemplate> </asp:TemplateField> </Columns> </asp:GridView>
The two ImageButton controls use the OnClientClick property to specify a JavaScript Confirm dialog that will execute the postback only after an explicit user confirmation. This isn't done from the grid's RowCreated event as it is in the ManageCategories.aspx page because here the ImageButton controls are defined explicitly (and thus you can set their OnClientClick property directly from the markup code), while in the previous page the ImageButton was created dynamically by the CommandField column.
The code for the Article rating is defined here also, and it's in a RatingDisplay control, whose Value property is bound to the Article's AverageRating property.
Finally, on the right side of the article's title there is an Image representing a key indicating that the article requires the user to login before viewing it. This image is only displayed when the article's OnlyForMembers property is true and the current user is anonymous.
The ArticleListing code-behind classes define the properties described earlier in the "Design" section. They are just wrappers for private fields that are made persistent across postbacks by means of the SaveControlState and LoadControlState protected methods, which save and load the properties to and from the ControlState part of the control's ViewState (this is new functionality in ASP.NET 2.0):
public partial class ArticleListing : System.Web.UI.UserControl { private bool _enableHighlighter = true; public bool EnableHighlighter { get { return _enableHighlighter; } set { _enableHighlighter = value; } } private bool _publishedOnly = true; public bool PublishedOnly { get { return _publishedOnly; } set { _publishedOnly = value; objArticles.SelectParameters[ "publishedOnly"].DefaultValue = value.ToString(); } } private bool _showCategoryPicker = true; public bool ShowCategoryPicker { get { return _showCategoryPicker; } set { _showCategoryPicker = value; ddlCategories.Visible = value; lblCategoryPicker.Visible = value; lblSeparator.Visible = value; } } private bool _showPageSizePicker = true; public bool ShowPageSizePicker { get { return _showPageSizePicker; } set { _showPageSizePicker = value; ddlArticlesPerPage.Visible = value; lblPageSizePicker.Visible = value; } } private bool _enablePaging = true; public bool EnablePaging { get { return _enablePaging; } set { _enablePaging = value; gvwArticles.PagerSettings.Visible = value; } } private bool _userCanEdit = false; protected bool UserCanEdit { get { return _userCanEdit; } set { _userCanEdit = value; } } private string _userCountry = ""; private string _userState = ""; private string _userCity = ""; protected override void LoadControlState(object savedState) { object[] ctlState = (object[])savedState; base.LoadControlState(ctlState[0]); this.EnableHighlighter = (bool)ctlState[1]; this.PublishedOnly = (bool)ctlState[2]; this.ShowCategoryPicker = (bool)ctlState[3]; this.ShowPageSizePicker = (bool)ctlState[4]; this.EnablePaging = (bool)ctlState[5]; } protected override object SaveControlState() { object[] ctlState = new object[6]; ctlState[0] = base.SaveControlState(); ctlState[1] = this.EnableHighlighter; ctlState[2] = this.PublishedOnly; ctlState[3] = this.ShowCategoryPicker; ctlState[4] = this.ShowPageSizePicker; ctlState[5] = this.EnablePaging; return ctlState; } // other event handlers here... }
The PublishedOnly property in the preceding code has a setter that, in addition to setting the _publishedOnly private field, sets the default value of the publishedOnly parameter for the objArticles ObjectDataSource's Select method. The developer can plug the control into an end-user page and show only the published content by setting this property to true, or set it to false on an administrative page to show all the articles (published or not).
The class also has event handlers for a number of events of the GridView control and the two DropDown Lists. In the Page_ Load event handler, you can preselect the category for the category list, whose ID is passed on the querystring, if any. Then you do then same for the page size list, taking the value from the Articles configuration; if the specified value does not exist in the list you can add it to the list and then select it. Finally, you execute the DataBind with the current category filter and page size:
protected void Page_Load(object sender, EventArgs e) { if (!this.IsPostBack) { // preselect the category whose ID is passed in the querystring if (!string.IsNullOrEmpty(this.Request.QueryString["CatID"])) { ddlCategories.DataBind(); ddlCategories.SelectedValue = this.Request.QueryString["CatID"]; } // Set the page size as indicated in the config file. If an option for that // size doesn't already exist, first create and then select it. int pageSize = Globals.Settings.Articles.PageSize; if (ddlArticlesPerPage.Items.FindByValue(pageSize.ToString()) == null) { ddlArticlesPerPage.Items.Add(new ListItem(pageSize.ToString(), pageSize.ToString())); } ddlArticlesPerPage.SelectedValue = pageSize.ToString(); gvwArticles.PageSize = pageSize; gvwArticles.DataBind(); } }
If the user manually changes the page size, you need to change the GridView's PageSize property and set the PageIndex to 0 so that the grid displays the first page (because the current page becomes invalid when the user selects a different page size) and rebind the data from the DropDownList's SelectedIndexChanged event:
protected void ddlArticlesPerPage_SelectedIndexChanged(object sender, EventArgs e) { gvwArticles.PageSize = int.Parse(ddlArticlesPerPage.SelectedValue); gvwArticles.PageIndex = 0; gvwArticles.DataBind(); }
When a category is selected from the DropDownList you don't have to do anything to set the new filter, because that's automatically done by the GridView's ObjectDataSource. However, you must explicitly set the grid's page index to 0 because the current page index might be invalid with the new data (if, for example, there are no articles for the newly selected category, and you're on page 2). After setting the grid's page index you need to rebind the data:
protected void ddlCategories_SelectedIndexChanged(object sender, EventArgs e) { gvwArticles.PageIndex = 0; gvwArticles.DataBind(); }
When the editor clicks the Delete command you don't need to do anything, because it's the grid's companion ObjectDatasource that calls the Article.DeleteArticle static method with the ID of the row's article. This doesn't happen for the Approve command, of course, because that's not a CRUD method supported by the ObjectDataSource, so you need to handle it manually. More specifically, you handle the GridView's generic RowCommand event (raised for all types of commands, including Delete). First you verify that the event was raised because of a click on the Approve command, then you retrieve the ID of the article to approve from the event's CommandArgument parameter, execute the Article.ApproveArticle method, and lastly rebind the data to the control:
protected void gvwArticles_RowCommand(object sender, GridViewCommandEventArgs e) { if (e.CommandName == "Approve") { int articleID = int.Parse(e.CommandArgument.ToString()); Article.ApproveArticle(articleID); gvwArticles.DataBind(); } }
It is interesting to see how the articles that refer to events are highlighted to indicate that the article's location is close to the user's location. This is done in the grid's RowDataBound event, but only if the user is authenticated (otherwise her profile will not have the Country, State, and City properties) and if the control's EnableHighlighter property is true. The row is highlighted by applying a different CSS style class to the row itself. Remember that the article's State and City properties might contain multiple names separated by a semicolon — the value is therefore split on the semicolon character and the user's state and city are searched in the arrays resulting from the split:
protected void gvwArticles_RowDataBound(object sender, GridViewRowEventArgs e) { if (e.Row.RowType == DataControlRowType.DataRow && this.Page.User.Identity.IsAuthenticated && this.EnableHighlighter) { // hightlight the article row according to whether the current user's // city, state or country is found in the article's city, state or country Article article = (e.Row.DataItem as Article); if (article.Country.ToLower() == _userCountry) { e.Row.CssClass = "highlightcountry"; if (Array.IndexOf<string>( article.State.ToLower().Split(‘;'), _userState) > -1) { e.Row.CssClass = "highlightstate"; if (Array.IndexOf<string>( article.City.ToLower().Split(‘;'), _userCity) > -1) { e.Row.CssClass = "highlightcity"; } } } } }
The user's location is not retrieved directly from her profile in the preceding code because that would cause a read for each and every row. Instead, the user's country, state, and city are read only once from the profile, and saved in local variables. Page_Load cannot be used for this because the automatic binding done by the ObjectDataSource happens earlier, so we have to use the Page_Init event handler:
protected void Page_Init(object sender, EventArgs e) { this.Page.RegisterRequiresControlState(this); this.UserCanEdit = (this.Page.User.Identity.IsAuthenticated && (this.Page.User.IsInRole("Administrators") || this.Page.User.IsInRole("Editors"))); try { if (this.Page.User.Identity.IsAuthenticated) { _userCountry = this.Profile.Address.Country.ToLower(); _userState = this.Profile.Address.State.ToLower(); _userCity = this.Profile.Address.City.ToLower(); } } catch (Exception) { } }
The ArticleListing user control is now complete and ready to be plugged into the ASPX pages.
The ArticleListing control uses a secondary user control that you haven't seen yet. It's the RatingDisplay.ascx user control, which shows an image representing the average rating of an article. Many sites use star icons for this, but TheBeerHouse, in keeping with its theme, uses glasses of beer for this rating! There are nine different images, representing one glass, one glass and a half, two glasses, two glasses and a half, and so on until we get to five full glasses. The proper image will be chosen according to the average rating passed in to the Value property's setter function. The markup code only defines an Image and a Label:
<asp:Image runat="server" ID="imgRating" AlternateText="Average rating" /> <asp:Label runat="server" ID="lblNotRated" Text="(Not rated)" />
All the code is in the code-behind's setter function, which determines which image to display based on the value:
private double _value = 0.0; public double Value { get {return _value; } set { _value = value; if (_value >= 1) { lblNotRated.Visible = false; imgRating.Visible = true; imgRating.AlternateText = "Average rating: " + _value.ToString("N1"); string url = "~/images/stars{0}.gif"; if (_value <= 1.3) url = string.Format(url, "10"); else if (_value <= 1.8) url = string.Format(url, "15"); else if (_value <= 2.3) url = string.Format(url, "20"); else if (_value <= 2.8) url = string.Format(url, "25"); else if (_value <= 3.3) url = string.Format(url, "30"); else if (_value <= 3.8) url = string.Format(url, "35"); else if (_value <= 4.3) url = string.Format(url, "40"); else if (_value <= 4.8) url = string.Format(url, "45"); else url = string.Format(url, "50"); imgRating.ImageUrl = url; } else { lblNotRated.Visible = true; imgRating.Visible = false; } } }
In addition to the graphical representation, a more accurate numerical value is shown in the image's alternate text (tooltip).
This page just has an instance of the ArticlesListing control, with the PublishedOnly property set to false so that it displays all articles:
<%@ Register Src="../Controls/ArticleListing.ascx" TagName="ArticleListing" TagPrefix="mb" %> ... <mb:ArticleListing id="ArticleListing1" runat="server" PublishedOnly="False" />
This page allows Administrators, Editors, and Contributors to add new articles or edit existing ones. It decides whether to use edit or insert mode according to whether an ArticleID parameter was passed on the querystring. If it was, then the page loads in edit mode for that article, but only if the user is an Administrator or Editor (the edit mode is only available to Administrators and Editors, while the insert mode is also available to Contributors as well). This security check must be done programmatically, instead of declaratively from the web.config file. Figure 5-8 is a screenshot of the page while in edit mode for an article.
As you might guess from studying this picture, it uses a DetailsView with a few read-only fields (ID, AddedDate, and AddedBy as usual, but also ViewCount, Votes, and Rating) and many other editable fields. The Body field uses the open-source FCKeditor described earlier. It is declared on the page as any other custom control, but it requires some configuration first. To set up FCK you must download two packages from www.fckeditor.net:
FCKeditor (which at the time of writing is in version 2.1) includes the set of HTML pages and JavaScript files that implement the control. The control can be used not only with ASP.NET, but also with ASP, JSP, PHP, and normal HTML pages. This first package includes the "hostindependent" code, and some ASP/ASP.NET/JSP/PHP pages that implement an integrated file browser and file uploader.
FCKedit.Net is the .NET custom control that wraps the HTML and JavaScript code of the editor.
You unzip the first package into an FCKeditor folder, underneath the site's root folder. Then you unzip the second package and put the compiled dll underneath the site's bin folder. The .NET custom control class has a number of properties that let you customize the look and feel of the editor; some properties can only be set in the fckconfig.js file found under the FCKeditor folder. The global FCKConfig JavaScript editor enables you to configure many properties, such as whether the File Browser and Image Upload commands are enabled. Here's how to disable them in code (we already have our own file uploader so we don't want to use it here, and we don't want to use the file browser for security reasons):
FCKConfig.LinkBrowser = false; FCKConfig.ImageBrowser = false; FCKConfig.LinkUpload = false; FCKConfig.ImageUpload = false;
You can even create a customized editor toolbar by adding the commands that you want to implement. For example, this shows how you can define a toolbar named "TheBeerHouse" with commands to format the text, insert smileys and images, but not insert input controls or Flash animations:
FCKConfig.ToolbarSets["TheBeerHouse"] = [ [‘Source','Preview','Templates'], [‘Cut','Copy','Paste','PasteText','PasteWord','-','Print','SpellCheck'], [‘Undo','Redo','-','Find','Replace','-','SelectAll','RemoveFormat'], [‘Bold','Italic','Underline','StrikeThrough','-','Subscript','Superscript'], [‘OrderedList','UnorderedList','-','Outdent','Indent'], [‘JustifyLeft','JustifyCenter','JustifyRight','JustifyFull'], [‘Link','Unlink','Anchor'], [‘Image','Table','Rule','Smiley','SpecialChar','UniversalKey'], [‘Style','FontFormat','FontName','FontSize'], [‘TextColor','BGColor'], [‘About'] ];
Many other configurations can be set directly from the ASP.NET page that hosts the control, as you'll see shortly.
The AddEditArticle.aspx page first declares the ObjectDataSource, as usual. Then it uses the Article's GetArticleByID, DeleteArticle, InsertArticle, and UpdateArticle methods for the complete management of a single article:
<asp:ObjectDataSource ID="objCurrArticle" runat="server" TypeName="MB.TheBeerHouse.BLL.Articles.Article" DeleteMethod="DeleteArticle" InsertMethod="InsertArticle" SelectMethod="GetArticleByID" UpdateMethod="UpdateArticle"> <SelectParameters> <asp:QueryStringParameter Name="articleID" QueryStringField="ID" Type="Int32" /> </SelectParameters> </asp:ObjectDataSource>
The only explicit parameter is the optional articleID value passed on the querystring. The parameters for the Delete, Update, and Insert methods are automatically inferred from the DetailsView's fields and the method's signature. Two other ObjectDataSource controls are used on the page: one for the DropDownList with the available categories, and the other for the DropDownList with countries:
<asp:ObjectDataSource ID="objAllCategories" runat="server" TypeName="MB.TheBeerHouse.BLL.Articles.Category" SelectMethod="GetCategories"> </asp:ObjectDataSource> <asp:ObjectDataSource ID="objAllCountries" runat="server" TypeName="MB.TheBeerHouse.UI.Helpers" SelectMethod="GetCountries"> <SelectParameters> <asp:Parameter DefaultValue="true" Name="insertEmpty" Type="Boolean" /> </SelectParameters> </asp:ObjectDataSource>
The DetailsView control uses a BoundField for some read-only fields mentioned earlier, and uses TemplateFields for the editable fields. We don't use DataBound fields for the editable properties because that wouldn't support the use of validators. Many of the fields are edited by means of a textbox for text properties, a checkbox for Boolean properties (e.g., OnlyForMembers, Listed, Approved), or a DropDownList for lists of values (Country and Category). The following code shows some representative fields:
<asp:DetailsView ID="dvwArticle" runat="server" AutoGenerateDeleteButton="True" AutoGenerateEditButton="True" AutoGenerateInsertButton="True" AutoGenerateRows="False" DataKeyNames="ID" DataSourceID="objCurrArticle" DefaultMode="Insert" HeaderText="Article Details" OnItemCreated="dvwArticle_ItemCreated" OnDataBound="dvwArticle_DataBound" OnModeChanged="dvwArticle_ModeChanged"> <FieldHeaderStyle Width="100px" /> <Fields> <asp:BoundField DataField="ID" HeaderText="ID" InsertVisible="False" ReadOnly="True" /> <asp:BoundField DataField="AddedDate" HeaderText="AddedDate" InsertVisible="False" ReadOnly="True" DataFormatString="{0:f}" /> <!-- other read-only BoundFields... --> <asp:TemplateField HeaderText="CategoryID"> <ItemTemplate> <asp:Label ID="lblCategory" runat="server" Text='<%# Eval("CategoryTitle") %>'></asp:Label> </ItemTemplate> <EditItemTemplate> <asp:DropDownList ID="ddlCategories" runat="server" DataSourceID="objAllCategories" DataTextField="Title" DataValueField="ID" SelectedValue='<%# Bind("CategoryID") %>' Width="100%" /> </EditItemTemplate> </asp:TemplateField> <asp:TemplateField HeaderText="Title"> <ItemTemplate> <asp:Label ID="lblTitle" runat="server" Text='<%# Eval("Title") %>'></asp:Label> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtTitle" runat="server" Text='<%# Bind("Title") %>' Width="100%" MaxLength="256"></asp:TextBox> <asp:RequiredFieldValidator ID="valRequireTitle" runat="server" ControlToValidate="txtTitle" SetFocusOnError="true" Text="The Title field is required." ToolTip="The Title field is required." Display="Dynamic"></asp:RequiredFieldValidator> </EditItemTemplate> </asp:TemplateField> <asp:TemplateField HeaderText="Abstract" SortExpression="Abstract"> <ItemTemplate> <asp:Label ID="lblAbstract" runat="server" Text='<%# Eval("Abstract") %>'></asp:Label> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtAbstract" runat="server" Text='<%# Bind("Abstract") %>' Rows="5" TextMode="MultiLine" Width="100%" MaxLength="4000"></asp:TextBox> </EditItemTemplate> </asp:TemplateField> <asp:TemplateField HeaderText="Body" SortExpression="Body"> <ItemTemplate> <asp:Label ID="lblBody" runat="server" Text='<%# Eval("Body") %>'></asp:Label> </ItemTemplate> <EditItemTemplate> <fckeditorv2:fckeditor id="txtBody" runat="server" Value='<%# Bind("Body") %>' ToolbarSet="TheBeerHouse" Height="400px" Width="100%" /> </EditItemTemplate> </asp:TemplateField> <asp:TemplateField HeaderText="Approved" SortExpression="Approved"> <ItemTemplate> <asp:CheckBox ID="chkApproved" runat="server" Checked='<%# Eval("Approved") %>' Enabled="False" /> </ItemTemplate> <EditItemTemplate> <asp:CheckBox ID="chkApproved" runat="server" Checked='<%# Bind("Approved") %>' /> </EditItemTemplate> </asp:TemplateField> <!-- other editable TemplateFields... --> </Fields> </asp:DetailsView> <p></p> <mb:FileUploader id="FileUploader1" runat="server"> </mb:FileUploader>
The declaration of the FCKeditor shows the use of the ToolbarSet property, which references the TheBeerHouse toolbar defined earlier in the JavaScript configuration file.
The DetailsView's mode is set to insert mode by default, but if an ID parameter is found on the querystring when the page loads, then it is switched to edit mode if the current user is an Administrator or Editor, and not just a Contributor. If a user not belonging to one of those two roles tries to manually load the page with an ID on the querystring, the page will throw a SecurityException:
protected void Page_Load(object sender, EventArgs e) { if (!this.IsPostBack) { if (!string.IsNullOrEmpty(this.Request.QueryString["ID"])) { if (this.User.Identity.IsAuthenticated && (this.User.IsInRole("Administrators") || this.User.IsInRole("Editors"))) { dvwArticle.ChangeMode(DetailsViewMode.Edit); } else throw new SecurityException( "You are not allowed to edit existent articles!"); } } }
The FCKeditor requires another property, BasePath, that points to the URL of the FCKeditor folder that contains all its HTML, JavaScript, and image files. Because the BasePath property is not bindable, we have to handle the DetailsView's ItemCreated event (raised after all the fields' controls have been created); and, inside this event handler, we have to get a reference to the txtBody (FCKeditor) and set its BasePath property to the URL of the folder:
protected void dvwArticle_ItemCreated(object sender, EventArgs e) { Control ctl = dvwArticle.FindControl("txtBody"); if (ctl != null) { FCKeditor txtBody = ctl as FCKeditor; txtBody.BasePath = this.BaseUrl + "FCKeditor/"; } }
There's one last detail to cover here: You should preselect the checkboxes that make the article listed and to allow comments when the DetailsView is in insert mode. You should also select and enable the Approved checkbox, but only if the current user belongs to the Administrators or Editors roles. You might think you could do this by defining an InsertItemTemplate (in addition to the EditItemTemplate) for the Listed, Approved, and AllowComments fields and then setting their Checked property to true, but it must be set to a binding expression in order to support the ObjectDataSource's Insert method correctly. The only solution I've found is to handle the DetailsView's DataBound event, get a reference to the checkboxes, and programmatically set their Checked property from there:
protected void dvwArticle_DataBound(object sender, EventArgs e) { if (dvwArticle.CurrentMode == DetailsViewMode.Insert) { CheckBox chkApproved = dvwArticle.FindControl("chkApproved") as CheckBox; CheckBox chkListed = dvwArticle.FindControl("chkListed") as CheckBox; CheckBox chkCommentsEnabled = dvwArticle.FindControl("chkCommentsEnabled") as CheckBox; chkListed.Checked = true; chkCommentsEnabled.Checked = true; bool canApprove = (this.User.IsInRole("Administrators") || this.User.IsInRole("Editors")); chkApproved.Enabled = canApprove; chkApproved.Checked = canApprove; } }
This page is located under the ~/Admin/ folder and it displays all comments of all articles, from the newest to the oldest, and allows an administrator or an editor to moderate the feedback by editing or deleting comments that may not be considered suitable. The page uses a paginable GridView for displaying the comments and a separate DetailsView on the bottom of the page to edit the comment selected in the grid. Figure 5-9 shows a screenshot of this page.
I won't cover the code for this page in detail because it's similar to other code that's already been discussed. The pagination for the GridView is implemented the same way as the pagination in the ArticleListing user control, and the editable DetailsView connected to the GridView's selected row is like the DetailsView in the page ManageCategories.aspx. You can refer to the downloadable code for the complete implementation.
This is the first end-user page of this module, located in the site's root folder. Its only purpose is to display the article categories in a nice format, so that the reader can easily and clearly understand what the various categories are about and quickly jump to their content by clicking on the category's title. Figure 5-10 shows this page.
The list of categories is implemented as a DataList with two columns (with repeatColumns set to 2). We don't need pagination, sorting, or editing features here, so a simple DataList with support for repeated columns is adequate. Following is the code for the DataList and its companion ObjectDataSource:
<asp:ObjectDataSource ID="objAllCategories" runat="server" SelectMethod="GetCategories" TypeName="MB.TheBeerHouse.BLL.Articles.Category"> </asp:ObjectDataSource> <asp:DataList ID="dlstCategories" EnableTheming="false" runat="server" DataSourceID="objAllCategories" DataKeyField="ID" GridLines="None" Width="100%" RepeatColumns="2"> <ItemTemplate> <table cellpadding="6" style="width: 100%;"> <tr> <td style="width: 1px;"> <asp:HyperLink runat="server" ID="lnkCatImage" NavigateUrl='<%# "BrowseArticles.aspx?CatID="+ Eval("ID") %>' > <asp:Image runat="server" ID="imgCategory" BorderWidth="0px" AlternateText='<%# Eval("Title") %>' ImageUrl='<%# Eval("ImageUrl") %>' /> </asp:HyperLink> </td> <td> <div class="sectionsubtitle"> <asp:HyperLink runat="server" ID="lnkCatRss" NavigateUrl='<%# "GetArticlesRss.aspx?CatID="+ Eval("ID") %>'> <img style="border-width: 0px;" src="Images/rss.gif" alt="Get the RSS for this category" /></asp:HyperLink> <asp:HyperLink runat="server" ID="lnkCatTitle" Text='<%# Eval("Title") %>' NavigateUrl='<%# "BrowseArticles.aspx?CatID="+ Eval("ID") %>' /> </div> <br /> <asp:Literal runat="server" ID="lblDescription" Text='<%# Eval("Description") %>' /> </td> </tr> </table> </ItemTemplate> </asp:DataList>
The category's image and the title both link to the BrowseArticle.aspx page, with a CatID parameter on the querystring equal to the ID of the clicked row. This is the page we'll cover next. The Show Categories.aspx has no code in the code-behind file because its data values are generated by the DataList and ObjectDataSource pair, and there are no actions or events we need to handle.
This is the end-user version of the ManageArticles.aspx page presented earlier. It only shows published content instead of all content, but otherwise it's the same as it just declares an instance of the shared ArticleListing user control:
<mb:ArticleListing id="ArticleListing1" runat="server" PublishedOnly="True" />
Figure 5-11 represents a screenshot of the page. Note that the page is available to all users, but because the current user is anonymous and the two articles listed on the page have their OnlyForMembers property set to false, the key image is shown next to them. If the user clicks the article's title, she will be redirected to the login page.
This end-user page outputs the whole article's text, and all its other information (author, release date, average rating, number of views, etc.). At the bottom of the page it has input controls to let the user rate the article (from one to five glasses of beer) and to submit comments. All comments are listed in chronological order, on a single page, so that it's easy to follow the discussion. When the page is loaded by an editor or an administrator, some additional buttons to delete, approve, and edit the article are also visible, and this also applies to the comments. Figure 5-12 shows a screenshot of the page as seen by an end user.
The code that renders the title, rating, and other information in the upper box is very similar to the code used in the ArticleListing user control, so I won't list it here again. The article's content is displayed in a simple Literal control. Let's consider the controls that allow the user to rate the article and provide feedback. The possible rating values are listed in a DropDownList, and below that there's a Label that tells the user what rating she selected. The Label will be made visible only after the user rates the article, of course, and once that happens the DropDownList will be hidden. This will be true also for future loads of the same article by the same user, because the vote will be remembered in a cookie, as you'll see shortly in the code-behind file:
<div class="sectiontitle">How would you rate this article?</div> <asp:DropDownList runat="server" ID="ddlRatings"> <asp:ListItem Value="1" Text="1 beer" /> <asp:ListItem Value="2" Text="2 beers" /> <asp:ListItem Value="3" Text="3 beers" /> <asp:ListItem Value="4" Text="4 beers" /> <asp:ListItem Value="5" Text="5 beers" Selected="true" /> </asp:DropDownList> <asp:Button runat="server" ID="btnRate" Text="Rate" OnClick="btnRate_Click" CausesValidation="false" /> <asp:Literal runat="server" ID="lblUserRating" Visible="False" Text="Your rated this article {0} beer(s). Thank you for your feedback." />
The rest of the page defines a DataList and an accompanying ObjectDataSource for listing the current comments. I won't show this piece because there's nothing new here. Instead, note the DetailsView at the bottom of the page, which is used to post a new comment and edit existing ones (when a comment in the DataList just shown is selected by an editor or administrator):
<asp:DetailsView id="dvwComment" runat="server" AutoGenerateInsertButton="True" AutoGenerateEditButton="true" AutoGenerateRows="False" DataSourceID="objCurrComment" DefaultMode="Insert" OnItemInserted="dvwComment_ItemInserted" OnItemCommand="dvwComment_ItemCommand" DataKeyNames="ID" OnItemUpdated="dvwComment_ItemUpdated" OnItemCreated="dvwComment_ItemCreated"> <FieldHeaderStyle Width="80px" /> <Fields> <asp:BoundField DataField="ID" HeaderText="ID:" ReadOnly="True" InsertVisible="False" /> <asp:BoundField DataField="AddedDate" HeaderText="AddedDate:" InsertVisible="False" ReadOnly="True"/> <asp:BoundField DataField="AddedByIP" HeaderText="UserIP:" ReadOnly="True" InsertVisible="False" /> <asp:TemplateField HeaderText="Name:"> <ItemTemplate> <asp:Label ID="lblAddedBy" runat="server" Text='<%# Eval("AddedBy") %>' /> </ItemTemplate> <InsertItemTemplate> <asp:TextBox ID="txtAddedBy" runat="server" Text='<%# Bind("AddedBy") %>' MaxLength="256" Width="100%" /> <asp:RequiredFieldValidator ID="valRequireAddedBy" runat="server" ControlToValidate="txtAddedBy" SetFocusOnError="true" Text="Your name is required." Display="Dynamic" /> </InsertItemTemplate> </asp:TemplateField> <asp:TemplateField HeaderText="E-mail:"> <ItemTemplate> <asp:HyperLink ID="lnkAddedByEmail" runat="server" Text='<%# Eval("AddedByEmail") %>' NavigateUrl='<%# "mailto:" + Eval("AddedByEmail") %>' /> </ItemTemplate> <InsertItemTemplate> <asp:TextBox ID="txtAddedByEmail" runat="server" Text='<%# Bind("AddedByEmail") %>' MaxLength="256" Width="100%" /> <asp:RequiredFieldValidator ID="valRequireAddedByEmail" runat="server" ControlToValidate="txtAddedByEmail" SetFocusOnError="true" Text="Your e-mail is required." Display="Dynamic" /> <asp:RegularExpressionValidator runat="server" ID="valEmailPattern" Display="Dynamic" SetFocusOnError="true" ControlToValidate="txtAddedByEmail" ValidationExpression="\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*" Text="The e-mail is not well-formed." /> </InsertItemTemplate> </asp:TemplateField> <asp:TemplateField HeaderText="Comment:"> <ItemTemplate> <asp:Label ID="lblBody" runat="server" Text='<%# Eval("Body") %>' /> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtBody" runat="server" Text='<%# Bind("Body") %>' TextMode="MultiLine" Rows="5" Width="100%"></asp:TextBox> <asp:RequiredFieldValidator ID="valRequireBody" runat="server" ControlToValidate="txtBody" SetFocusOnError="true" Text="The comment text is required." Display="Dynamic" /> </EditItemTemplate> </asp:TemplateField> </Fields> </asp:DetailsView>
One unique aspect of the preceding code is that the AddedBy and AddedByEmail fields use the TemplateField's InsertItemTemplate instead of the EditItemTemplate that we used in the other DetailsView controls. This is because these fields must be editable only when adding new comments, whereas they are read-only when a comment is being edited. If we had used the EditItemTemplate, as we did for the Body field, that template would have been used for insert mode (unless you specify an InsertItemTemplate that's like the ItemTemplate, but that means replicating code without any advantage). The InsertItemTemplate is not used while the control is in edit mode instead; in that case the ItemTemplate is used, with the result that those fields cannot be changed.
When the page loads, it reads the ID parameter from the querystring. The ID parameter must be specified; otherwise, the page will throw an exception because it doesn't know which article to load. After an Article object has been loaded for the specified article, you must also confirm that the article is currently published (it is not a future or retired article) and that the current user can read it (if the OnlyForMembers property is true, they have to be logged in). You must check these conditions because a cheating user might try to enter an ID in the URL manually, even if the article isn't listed in the BrowseArticles.aspx page. If everything is OK, the article's view count is incremented, and Labels and other controls on the page are filled with the article's data. Finally, you check whether the current user has already rated the article, in which case you show her rating and hide the ratings DropDownList to prevent her from recording a new vote. Here's the code for the Page_Load event handler where all this is done:
protected void Page_Load(object sender, EventArgs e) { if (string.IsNullOrEmpty(this.Request.QueryString["ID"])) throw new ApplicationException("Missing parameter on the querystring."); else _articleID = int.Parse(this.Request.QueryString["ID"]); if (!this.IsPostBack) { // try to load the article with the specified ID, and raise an exception // if it doesn't exist Article article = Article.GetArticleByID(_articleID); if (article == null) throw new ApplicationException("No article found for the specified ID."); // Check if the article is published (approved + released + not yet expired). // If not, continue only if the current user is an Administrator or an Editor if (!article.Published) { if (!this.UserCanEdit) { throw new SecurityException(@" What are you trying to do??? You're not allowed to do view this article!"); } } // if the article has the OnlyForMembers = true, and the current user // is anonymous, redirect to the login page if (article.OnlyForMembers && !this.User.Identity.IsAuthenticated) this.RequestLogin(); article.IncrementViewCount(); // if we get here, display all article's data on the page this.Title += article.Title; lblTitle.Text = article.Title; lblNotApproved.Visible = !article.Approved; lblAddedBy.Text = article.AddedBy; lblReleaseDate.Text = article.ReleaseDate.ToShortDateString(); lblCategory.Text = article.CategoryTitle; lblLocation.Visible = (article.Location.Length > 0); if (lblLocation.Visible) lblLocation.Text = string.Format(lblLocation.Text, article.Location); lblRating.Text = string.Format(lblRating.Text, article.Votes); ratDisplay.Value = article.AverageRating; ratDisplay.Visible = (article.Votes > 0); lblViews.Text = string.Format(lblViews.Text, article.ViewCount); lblAbstract.Text = article.Abstract; lblBody.Text = article.Body; panComments.Visible = article.CommentsEnabled; panEditArticle.Visible = this.UserCanEdit; btnApprove.Visible = !article.Approved; lnkEditArticle.NavigateUrl = string.Format( lnkEditArticle.NavigateUrl, _articleID); // hide the rating box controls if the current user // has already voted for this article int userRating = GetUserRating(); if (userRating > 0) ShowUserRating(userRating); } }
The code for retrieving and displaying the user's vote will be shown in a moment, but first let's examine how the vote is remembered. When the Rate button is clicked, you retrieve the selected value from the Rating DropDownList and use it to call Article.RateArticle. Then you save the rating into a cookie named Rating_Article_{ArticleID}, and later you can retrieve it from the cookie:
protected void btnRate_Click(object sender, EventArgs e) { // check whether the user has already rated this article int userRating = GetUserRating(); if (userRating > 0) { ShowUserRating(userRating); } else { // rate the article, then create a cookie to remember this user's rating userRating = ddlRatings.SelectedIndex + 1; Article.RateArticle(_articleID, userRating); ShowUserRating(userRating); HttpCookie cookie = new HttpCookie( "Rating_Article" + _articleID.ToString(), userRating.ToString()); cookie.Expires = DateTime.Now.AddDays( Globals.Settings.Articles.RatingLockInterval); this.Response.Cookies.Add(cookie); } }
Notice that the cookie is set to have a lifetime of the number of days specified by the RatingLockInterval configuration setting. The default value is 15, but you can change it to a more suitable value according to a number of factors: For example, if your articles are not changed very often you will likely want to specify a much longer cookie lifetime. To determine whether a user has already voted, you simply need to check whether a cookie with that particular name exists. If it does, you read its value to get the rating:
protected int GetUserRating() { int rating = 0; HttpCookie cookie = this.Request.Cookies[ "Rating_Article" + _articleID.ToString()]; if (cookie != null) rating = int.Parse(cookie.Value); return rating; }
If the user has already voted on the article, you show their rating with the following method, which both hides the DropDownList and shows the Label with the message:
protected void ShowUserRating(int rating) { lblUserRating.Text = string.Format(lblUserRating.Text, rating); ddlRatings.Visible = false; btnRate.Visible = false; lblUserRating.Visible = true; }
One last detail: When the DetailsView is created in insert mode, you can prefill the AddedBy and AddedByEmail fields with the value retrieved from the user's membership account, if the user is authenticated:
protected void dvwComment_ItemCreated(object sender, EventArgs e) { if (dvwComment.CurrentMode == DetailsViewMode.Insert && this.User.Identity.IsAuthenticated) { MembershipUser user = Membership.GetUser(); (dvwComment.FindControl("txtAddedBy") as TextBox).Text = user.UserName; (dvwComment.FindControl("txtAddedByEmail") as TextBox).Text = user.Email; } }
Figure 5-13 shows the DataList with all comments, and the DetailsView while editing one.
Note |
Security warning: Never store a user's account number, or any sensitive data, in a cookie. In this application the article rating is not sensitive in any way — you have already tallied the rating in the database and the cookie is only used to display the rating made by that user. Using the cookie frees you from having to store each user's rating for each article in the database. You only need to store the total rating in the database, which is far more efficient. |
This page returns the RSS feed for the "n" most recent articles (where "n" is specified in web.config) of a specific category, or for any category, according to the CatID parameter on the querystring. In the "Design" section of this chapter you saw the schema of a valid RSS 2.0 document. Here we apply that structure to output a set of Article entries retrieved from the DB using a Repeater control. We don't need a more advanced data control because the structure is simple and is completely format-free. Let's examine the markup code of this page, and then I'll point out a few interesting details:
<%@ Page Language="C#" AutoEventWireup="true" ContentType="text/xml" EnableTheming="false" CodeFile="GetArticlesRss.aspx.cs" Inherits="GetArticlesRss" %> <head runat="server" visible="false"></head> <asp:Repeater id="rptRss" runat="server"> <HeaderTemplate> <rss version="2.0"> <channel> <title><![CDATA[The Beer House: <%# RssTitle %>]]></title> <link><%# FullBaseUrl %></link> <description>The Beer House: the site for beer fanatics</description> <copyright>Copyright 2005 by Marco Bellinaso</copyright> </HeaderTemplate> <ItemTemplate> <item> <title><![CDATA[<%# Eval("Title") %>]]></title> <author><![CDATA[<%# Eval("AddedBy") %>]]></author> <description><![CDATA[<%# Eval("Abstract") %>]]></description> <link><![CDATA[<%# FullBaseUrl + "ShowArticle.aspx?ID="+ Eval("ID") %>]]></link> <pubDate><%# string.Format("{0:R}", Eval("ReleaseDate")) %></pubDate> </item> </ItemTemplate> <FooterTemplate> </channel> </rss> </FooterTemplate> </asp:Repeater>
Here are the interesting points about this code:
The page's ContentType property is not set to "text/html" as usual, but to "text/xml". This is necessary for the browser to correctly recognize the output as an XML document.
There is a server-side <head> tag that has its Visible property set to false. The head tag is required if you've specified a theme in the web.config file, and even if you've explicitly disabled themes for this specific page, as we have done here (the page's EnabledTheming property is set to false). This is because ASP.NET has to dynamically create <link> tags for the CSS stylesheet files it finds under the theme folder when themes are enabled for the site. If the parent <head> tag is not found, it throws an exception. Having specified a <head> you can't leave it enabled because the output would not be a valid RSS document. Therefore, we specify a <head> but we make it invisible.
All data retrieved from the DB is wrapped within an XML CDATA section so that it won't be considered to be XML text. As mentioned earlier, CDATA is a way of quoting literal data so that it will not be considered XML data. This is necessary in our case because we can't assume that all text stored in the DB will contain only characters that won't conflict with XML special characters (such as < and >).
Our RSS feed will be consumed by desktop-based aggregators, or by other sites, so all the links you put into the feed (the link to the site's home page, and all the articles' links) must be complete links, and not relative to the site's root folder or to the current page. In other words, it must be something like www.contoso.com/GetArticlesRss.aspx instead of just /GetArticlesRss.aspx. For this reason, all links are prefixed by the value returned by FullBaseUrl, a custom property of the BasePage class that returns the site's base URL (e.g., http://www.contoso.com/).
The code-behind file of this page is simple: It just loads the "n" most recent published articles and binds them to the Repeater. The overloaded version of Article.GetArticles we're using here is the one that takes a Boolean value indicating whether you want the published content only, the ID of a parent category (the CatID querystring parameter, or 0 if the parameter is not specified), the index of the first row to retrieve (0 is used here), and the page size (read from the Articles configuration settings). Moreover, if the CatID parameter is passed on the querystring, the category with that ID is loaded, and its title is used to set the custom RssTitle property, to which the RSS's <title> element is bound. Here's the complete code:
public partial class GetArticlesRss : BasePage { private string _rssTitle = "Recent Articles"; public string RssTitle { get { return _rssTitle; } set { _rssTitle = value; } } protected void Page_Load(object sender, EventArgs e) { int categoryID = 0; if (!string.IsNullOrEmpty(this.Request.QueryString["CatID"])) { categoryID = int.Parse(this.Request.QueryString["CatID"]); Category category = Category.GetCategoryByID(categoryID); _rssTitle = category.Title; } List<Article> articles = Article.GetArticles(true, categoryID, 0, Globals.Settings.Articles.RssItems); rptRss.DataSource = articles; rptRss.DataBind(); } }
The final piece of code for this chapter's module is the RssReader user control. In the ascx file, you define a DataList that displays the title and description of the bound RSS items and makes the title a link pointing to the full article. It also has a header that will be set to the channel's title, a graphical link pointing to the source RSS file, and a link at the bottom that points to a page with more content:
<div class="sectiontitle"> <asp:Literal runat="server" ID="lblTitle"/> <asp:HyperLink ID="lnkRss" runat="server" ToolTip="Get the RSS for this content"> <asp:Image runat="server" ID="imgRss" ImageUrl="~/Images/rss.gif" AlternateText="Get RSS feed" /> </asp:HyperLink> </div> <asp:DataList id="dlstRss" Runat="server" EnableViewState="False"> <ItemTemplate> <small><%# Eval("PubDate", "{0:d}") %></small> <br> <div class="sectionsubtitle"><asp:HyperLink Runat="server" ID="lnkTitle" NavigateUrl='<%# Eval("Link") %>' Text='<%# Eval("Title") %>' /></div> <%# Eval("Description") %> </ItemTemplate> </asp:DataList> <p style="text-align: right;"> <small><asp:HyperLink Runat="server" ID="lnkMore" /></small></p>
The first part of the control's code-behind file defines all the custom properties defined in the "Design" section that are used to make this user control a generic RSS reader, and not specific to our site's content and settings:
public partial class RssReader : System.Web.UI.UserControl { public string RssUrl { get { return lnkRss.NavigateUrl; } set { string url = value; if (value.StartsWith("/") || value.StartsWith("~/")) { url = (this.Page as BasePage).FullBaseUrl + value; url = url.Replace("~/", ""); } lnkRss.NavigateUrl = url; } } public string Title { get { return lblTitle.Text; } set { lblTitle.Text = value; } } public int RepeatColumns { get { return dlstRss.RepeatColumns; } set { dlstRss.RepeatColumns = value; } } public string MoreUrl { get { return lnkMore.NavigateUrl; } set { lnkMore.NavigateUrl = value; } } public string MoreText { get { return lnkMore.Text; } set { lnkMore.Text = value; } }
Note that you don't need to persist the property values in the ControlState here, as we did in previous controls, because all properties wrap a property of some other server-side control, and it will be that other control's job to take care of persisting the values. All the real work of loading and binding the data is done in Page_Load. First, you load the RSS document into a System.Xml.XmlDataDocument, a class that allows you to easily work with XML data. For each <item> element found in the document, you create a new row in a DataTable whose schema (the number and types of columns) is also generated from within this event handler. Finally, the DataTable is bound to the DataList:
protected void Page_Load(object sender, EventArgs e) { try { if (this.RssUrl.Length == 0) throw new ApplicationException("The RssUrl cannot be null."); // create a DataTable and fill it with the RSS data, // then bind it to the Repeater control XmlDataDocument feed = new XmlDataDocument(); feed.Load(this.RssUrl); XmlNodeList posts = feed.GetElementsByTagName("item"); DataTable table = new DataTable("Feed"); table.Columns.Add("Title", typeof(string)); table.Columns.Add("Description", typeof(string)); table.Columns.Add("Link", typeof(string)); table.Columns.Add("PubDate", typeof(DateTime)); foreach (XmlNode post in posts) { DataRow row = table.NewRow(); row["Title"] = post["title"].InnerText; row["Description"] = post["description"].InnerText.Trim(); row["Link"] = post["link"].InnerText; row["PubDate"] = DateTime.Parse(post["pubDate"].InnerText); table.Rows.Add(row); } dlstRss.DataSource = table; dlstRss.DataBind(); } catch (Exception) { this.Visible = false; } } }
Note that everything is put into a try...catch block, and if something goes wrong the whole control will be hidden. To test the control you can plug it into the site's home page, with the following declaration:
<mb:RssReader id="RssReader1" runat="server" Title="Latest Articles" RssUrl="~/GetArticlesRss.aspx" MoreText="More articles..." MoreUrl="~/BrowseArticles.aspx" />
The output is shown in Figure 5-14.
Many security checks have been implemented programmatically from the pages' code-behind. Now you need to edit the Admin folder's web.config file to add the Contributors role to the list of allowed roles for the AddEditArticle.aspx page:
<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0"> <system.web> <authorization> <allow roles="Administrators,Editors" /> <deny users="*" /> </authorization> </system.web> <location path="AddEditArticle.aspx"> <system.web> <authorization> <allow roles="Administrators,Editors,Contributors" /> <deny users="*" /> </authorization> </system.web> </location> <!--ManageUsers.aspx and EditUser.aspx pages... --> </configuration>
The more meaningful and short a URL can be, the better. Those URLs are easier to communicate to people, and easier for them to remember. The current URLs that allow us to browse articles for a specific category are already pretty short, but it's not easy to remember all category IDs by heart … and thus to remember the URL. ASP.NET 2.0 introduces a new section in web.config that allows us to map a virtual URL to a real URL: This section's name is urlMapping, and it's located under <system.web>. When the user types the virtual URL, the page located at the corresponding real URL will be loaded. Here's what you can write in web.config to make your sample categories easier to reach:
<urlMappings> <add url="~/articles/beer.aspx" mappedUrl="~/BrowseArticles.aspx?CatID=28" /> <add url="~/articles/events.aspx" mappedUrl="~/BrowseArticles.aspx?CatID=41" /> <add url="~/articles/news.aspx" mappedUrl="~/BrowseArticles.aspx?CatID=31" /> <add url="~/articles/photos.aspx" mappedUrl="~/BrowseArticles.aspx?CatID=40" /> <add url="~/articles/blog.aspx" mappedUrl="~/BrowseArticles.aspx?CatID=29" /> <add url="~/articles/faq.aspx" mappedUrl="~/BrowseArticles.aspx?CatID=42" /> </urlMappings>
Note that when the server receives a request for the virtual URL it doesn't make a normal redirect to the real URL. Before the page is loaded, ASP.NET rewrites the URL to the request's context. From that point on, the URL will be read from the context, and thus the correct URL will be retrieved. The bottom line is that the transition is done in memory on the server side, and users do not even see the real URL in their browser. Therefore, if a user maneuvers to ~/articles/beer.aspx she's really going to get ~/BrowseArticles.aspx?CatID=28. Furthermore, there is no physical page named ~/articles/beer.aspx.
Note |
This could have been done in ASP.NET 1.1, but you had to write your own custom HTTP module to do that, from which you would have used the current HttpContext's RewritePath method to do the in-memory redirect. |