Click here to Skip to main content
13,800,880 members
Click here to Skip to main content
Add your own
alternative version

Stats

16.6K views
672 downloads
15 bookmarked
Posted 7 Feb 2018
Licenced CPOL

Client and Server-Side Data Filtering, Sorting, and Pagination with Angular NgExTable

, 5 Dec 2018
Rate this:
Please Sign up or sign in to vote.
An Angular sample application presenting both client and server-side data filtering, sorting, and pagination with the fully configurable data grid tool NgExTable

Introduction

I used the data grid tool ngTable in AngularJS in our enterprise business applications and also wrote the articles using the ngTable for the data display and the CRUD sample applications. Unfortunately, the ngTable has not been migrated to the Angular version with the same functionality. I have tried some Angular data grids or tables from Internet resources, but cannot find one with the similar calling code structures and use experiences to the AngularJS ngTable. I finally created my own data grid, the NgExTable, which behaves the same as, and more configurable than, the AngularJS ngTable in respect to data searching, sorting, and pagination. The sample demo application has also been upgraded to using the Angular 6 and CLI setup.

The NgExTable has these features:

  • Using basic HTML code for the table tag, thus easy to define any table attributes, styles, and Angular pipes.
  • Simple and straightforward data binding to tr and td tags.
  • Setting column sorting by specifying sortBy directive in the th tag with changeable sorting icons.
  • Flexible and customizable pager (pagination component).
  • Resetting the pager after any change action by calling the ResetPagerService so that the code in the table-hosting components is very neat and simple (see the client-paging.component.ts and server-paging.component.ts as in the sample application).
  • Configurable table parameters, especially for those related to searching, sorting, and page-sizing commands.
  • The base library code is within a single folder that can be copied to, and used by, any other project even on different platforms.

Below are all files listed for the NgExTable library and demo project folders:

The major dependencies of an Angular web application all apply to the NgExTable. Please check the package.json file in the sample project to view those dependent library tools and files. You also need the node.js (recommended version 8.11.x LTS or above) and Angular CLI (recommended version 6.1.3 or above) installed globally on the local machine. Please check the node.js and Angular CLI documents for details.

Set Up and Run Sample Application

The downloaded sources contain different Visual Studio solution/project types. Please pick up those you would like and do the setup on your local machine.

NgExTable_AspNet5_Cli

  1. Download and unzip the source code file to your local work space.

  2. Go to physical location of your local work space, double click the npm_install.bat and ng_build.bat files sequentially under the SM.NgExTable.Web\ClientApp folder.

    Note: The ng build command may need to be executed every time after making any change in the TypeScript/JavaScript code. Whereas the execution of npm install is just needed whenever there is any update with the node module packages.

  3. Open the solution with the Visual Studio 2017 or 2015 (Update 3 or above) and rebuild the solution with the Visual Studio.

  4. Click the IIS Express toolbar command (or press F5) to start the sample application.

NgExTable_AspNetCore_Cli

  1. Download and unzip the source code file to your local work space.

  2. Go to physical location of your local work space, double click the npm_install.bat and ng_build.bat files sequentially under the SM.NgExTable.Web\AppDev folder (also see the same Note for setting up the NgExTable_AspNet5_Cli project).

  3. Open the solution with the Visual Studio 2017 (version 15.7 or above with ASP.NET Core 2.1) and rebuild the solution with the Visual Studio.

  4. Click the IIS Express toolbar command (or press F5) to start the sample application.

The first browser screen of the sample application looks like this:

You need to setup the server data source if opening the page for the server-side pagination demo. The Visual Studio solutions of server data source projects are attached to this article. I recommend setting up the SM.Store.CoreApi solution in your local machine. After opening and building the SM.Store.CoreApi solution with the Visual Studio, you can click the IIS Express button on the menu bar to start the data service API application.

No database needs to be set up initially since the ASP.NET Core 2.1 API application uses the in-memory database with the current configurations. The built-in starting page will show the response data in the JSON format obtained from a service method call, which is just a simple way to start the service application with the IIS Express on the development machine. You can now minimize the Visual Studio screen and keep the data service API running on the background. You can view this post for the details of the SM.Store.CoreApi data service project.

If you would like to use the legacy ASP.NET Web API 2 version of the data services, you can refer to this article for how to set up the data service project on your machine. The SM.Store.WebApi solution code is also included in the downloaded source of this article.

Before you run the sample application, you may check the RESTful API data service URL path in the ../app/NgExTableDemo/app.config.ts file to make sure that the correct WebApiRootUrl value is set for the running data services.

Table Structures and Working Mechanisms

Most other Angular data grids or tables use the table-level and column-level components based on the Angular component tree structures. However, this implementation approach doesn’t seem optimal for the development of web applications. It limits many custom options that can directly be added into the HTML elements, such as attributes, styles, additional elements in the columns, and even Angular pipes for data binding td tags. In addition, the HTML elements and styles of the component-type data grid are hard to be accommodated to the style guides or wireframes defined by the UX team if you develop enterprise business applications.

The NgExTable adds attribute directives into the regular table tag and children tags as the base structure, which is similar to the AngularJS “ng-table” attribute directive. It also relies on events to respond to the user actions and send the parameter data back to the parent component classes.

  1. The main coding structures related to the base table include the params attribute directive for the table tag, sortBy attribute directive for the th tag, and tableChanged event triggered from the TableMainDirective class. Both client-paging.component.html and server-paging.component.html files (table-hosting views) of the sample application show the example of using the directives and events in the HTML template.

    <table class="table table-condensed table-striped, bottom-border"
    
         [params]="pagingParams" (tableChanged)="onChangeTable($event)">
        <thead>
            <tr>
                <th [sortBy]="'ProductName'">Product Name</th>
                <th [sortBy]="'CategoryId'">Category ID</th>
                <th [sortBy]="'CategoryName'">Category Name</th>
                <th [sortBy]="'UnitPrice'">Unit Price</th>                
                <th [sortBy]="'StatusDescription'">Status</th>
                <th [sortBy]="'AvailableSince'">Available Since</th>
            </tr>
        </thead>
        <tbody>
            <tr *ngFor="let item of rows">
                <td>{{item.ProductName}}</td>
                <td align="center">{{item.CategoryId}}</td>
                <td>{{item.CategoryName}}</td>
                <td>{{item.UnitPrice | currency:"USD":true:"1.2-2"}}</td>
                <td>{{item.StatusDescription}}</td>
                <td>{{item.AvailableSince | date:"MM/dd/yyyy"}}</td>                
            </tr>
        </tbody>
    </table>
  2. The most important object to pass values back and forth for paging, sorting, and even related to data filtering is the pagingParams. The object definition is defined in the model-interface.ts:

    export interface PagingParams {
        pageSize: number;
        pageNumber: number;
        sortBy: string;
        sortDirection: string; //asc; desc;
        changeType: any;
    }

    The instance of the PagingParams is initiated in the ngOnInit method of the table-hosting component with seeding values. The object instance are then updated from multiple places, including the table-hosting component and its child structures TableMainDirective and PaginationComponent, in response to user request actions on the page.

    this.pagingParams = {
        pageSize: pageSize !== undefined ? pageSize : 10,
        pageNumber: 1,
        sortBy: "",
        sortDirection: "",
        changeType: TableChange.init
    }

    The changeType values are defined in the TableChange enum and updated for corresponding process status.

    export const enum TableChange {
        init,
        search,
        pageNumber,
        pageSize,
        sorting
    }
  3. The onChangeTable method in the table-hosting component receives the changes in the pagingParams, performs some tasks, and then calls to get the filtered, sorted, and paginated data items that will be loaded into the table.

    onChangeTable(params: PagingParams ):any {    
        this.pagingParams = params;
    
        //Perform any needed task.
        - - -
    
        //Call to get data and bind data items to table.
        - - -     
    }
  4. The column sortBy value is passed to the TableSortingDirective class in which the DOM elements of the column headers are constructed and sorting orders are set. The sortBy and sortDirection values in the pagingParams object updated in response to user request actions are also sent back to the table-hosting component relayed by the parent tableChanged event (see the details in the table-sorting.directive.ts and table-main.directives.ts if you are interested in the code logic).

  5. For any column that should be non-sortable, the sortBy directive can simply be omitted in the th tag.

Pagination Component

The pager is a separate component with its view template consisting of detailed HTML elements. It uses the similar workflow as the main table, i.e., passing the pagingParams object instance as the @Input to the PaginationComponent class and sending changed object instance back via the pageChanged event. The code in the table-hosting view is simple and straightforward:

<pagination *ngIf="pagingEnabled"

            [pagingParams]="pagingParams"

            [pagedLength]="pagedLength"                

            (pageChanged)="onChangeTable($event)">
</pagination>

The pageChanged event is routed to the same target method, onChangeTable, as the tableChanged event for the main table due to possible linked code logic among the page number, size, and sorting changes. You can use a different method, such as onChangePage, for the pager if you would like.

The page number is changed and number button highlighted from two triggering sources.

  1. Clicking on any page number. This will directly call the selectPage method in the PaginationComponent. The code workflow is all within the pager so that you usually don’t have to care it in the client calling logic.

    In view template:

    (click)="selectPage(page.number, $event)"

    In PaginationComponent class:

    selectPage(pageNumber: number, event?: Event): void {
        if (event) {
            //Set change type.
            this.pagingParams.changeType = TableChange.pageNumber;        
    	}
    
        //Update value in base pagingParams.
        this.pagingParams.pageNumber = pageNumber;
    
        //Set pagers.
        this.pages = this.getPages(); 
    
        //Fire event for pageNumber changeType.
        if (params.changeType == TableChange.pageNumber || params.changeType == TableChange.init)     {            
            this.pageChanged.emit(params); 
        }        
    }
  2. Other operations, such as data filtering, sorting, or page-size changes. The page number should passively be reset and correct number button highlighted. You need to add code into the table-hosting component and execute the code in the corresponding event methods, such as onTableChange, or in the processing logic after the data access. For example, below code block in the table-hosting component calls the selectPage method through the ResetPagerService for updating the pager (also see details in the later section, Reset Pager Service):

    //Call service method to update pager. 
    this.resetPagerService.updatePagerAfterData(this, this.pagingParams.changeType);

The change in page size is another user action on the pager. When selecting the number from the page-size dropdown, the onSizeChange event is triggered and corresponding processes are followed.

In the select tag of the paginationComponent view template:

(ngModelChange)="onSizeChange($event)">

In PaginationComponent class:

onSizeChange(event: any) {
    this.pagingParams.pageSize = event.value;
    //Set change type.
    this.pagingParams.changeType = TableChange.pageSize;
    - - -
    //Emit event for refresh data and pager.
    this.pageChanged.emit(params);
}

You also don’t have to touch the code within the paginationComponent. In the pageChanged target method of the table-hosting component, however, you may need to call the setPagerForSizeChange method from the paginationComponent in addition to the data access process using the changed page-size value (see the method in the pagination.component.ts file for details if interested).

if (this.pagingParams.changeType == TableChange.pageSize) {    
    //Reset pager.            
    this.paginationComponent.setPagerForSizeChange();                       
}

The built-in pager HTML template is included in the NgExTable folder. If you would like to update it with different layout and styles, you can directly edit the built-in template or replace it with your own one in the existing NgExTable folder.

Search Component

Strictly speaking, the search component is an application unit separate from the NgExTable. I did not directly add the filtering feature into the columns since real-world web applications rarely use the in-grid-column data filtering. Instead, I implemented a search engine with the Angular component code. The searchComponent class and its HTML view template in the sample application demonstrate how the structures and functionalities link to the NgExTable and perform the data filtering for the paginated grid display.

  1. Versatile input types are shown including multiple dropdown selections and double date pickers for date range entries. The inputs will be constructed as a JSON string or object which in turn serves as the request parameter object for the data retrieval. The structures and code are actually migrated from the AngulaJS Search Penal to the Angular search component.

  2. In the table-hosting view, the search tag is defined like this:

    <search *ngIf="searchEnabled" 
    
            [searchType] = "searchType"
    
            (searchChanged)="onChangeSearch($event)">
    </search>
  3. The searchChanged event is raised with searchParams object passed to the target method.

    //Construct searchParams object with input data.
    - - -
    //Raise event
    this.searchChanged.emit(this.searchParams);
  4. In the event target method, onChangeSearch, of the table-hosting component, conduct the processing logic and make the AJAX call for the data result set.

    onChangeSearch(searchParams: any) {
        this.pagingParams.changeType = TableChange.search;
        this.searchParams = searchParams;
    
        //Convert searchParams to request JSON object.
        - - - 
        //AJAX call to get data result sets.
        - - -
    } 
  5. Unlike the paginationComponent, you are responsible for coding all pieces of the searchComponent, its view template, and related parts in the table-hosting component. You also need to take care of some differences of searching logic between the client-side and server-side paginations.

  6. As for changes in page number, size, and sorting, changes for the searching also needs to reset the pager before and after the data access. You can call the methods in the ResetPagerService as described in the later section, Reset Pager Service.

Client-Side Pagination Workflow

The client-side pagination pattern loads all data records to the client cache and displays one page at a time based on the page-size setting. In the sample application, the client-paging.component.ts and associated files and structures show how this pattern works to obtain and display the filtered, sorted, and paginated data.

  1. The AJAX call occurs at the very first time for all needed data list items without using the search component. The local data source in the local-data-products.json file is used for the demo (see the getData method for details).

  2. Call the clientPaginationService.processData method with passing the pagingParams object instance to get the data rows from the cached data sources for the active page, and then set the paged data rows that are loaded to the table.

    let rtn = this.clientPaginationService.processData(this.pagingParams, this.currentDataList);
    this.rows = rtn.dataList;
  3. When the user performs a filter operation, the filter criteria is passed to the searchChanged event target method in which the logic basically repeats the steps #2 described above. The paged data rows are refreshed based on the filter criteria.

  4. The pager buttons and labels will then be refreshed accordingly with the data row changes.

  5. When the user clicks another page number button, changes column sorting, or selects a different page size, the updated information in the pagingParams object instance is passed to the tableChanged event target method. The logic in the method then repeats similar processes to those in the steps #2 - 4 above. The table rows and pager are then refreshed again for the new request.

Server-Side Pagination Workflow

For applications, especially enterprise business applications that use databases in large or dynamically increased size, the server-side pagination pattern is the practical and optimal solution due to its chunked data processing and transfer per request. The server-paging.component.ts and associated files and structures in the sample application show how this pattern works to obtain and display the filtered, sorted, and paginated data.

  1. The pagingParams and/or searchParams with default or updated values, are always set and used for each AJAX call to obtain the paged data rows.

  2. The request object, input, is constructed before making any data access call (see the getProductListRequest method for details).

    let input = this.getProductListRequest();
  3. The AJAX call is executed at the beginning and whenever the user performs the search action, changes the page number, applies the column soring, or selects a different page-size. The returned response object, data, includes the data list only for the active page and the total count of the data rows in the database based on the search criteria.

    let pThis = this;
    this.httpDataService.post(ApiUrlForProductList, input).subscribe(
        data => {                
            if (data && data.TotalCount > 0) {                    
                pThis.productList = data.Products;
                pThis.totalLength = data.TotalCount; 
    
                pThis.paginationComponent.totalItems = pThis.totalLength;                       
            }
        }
    );
  4. The pager buttons and labels will then be refreshed accordingly with the data row changes.

Reset Pager Service

Resetting paging parameters and pager for the change in page number can directly be done within the PaginationComponent. However for other operations, such as searching, sorting, and even for page size changes, the code logic in the table-hosting component side needs to cooperate with those child components, which could make the code bulky and repeated in the client callers. To resolve the issue, the ResetPagerService is created as a centralized resource to communicate between the table-hosting components and different child components.

The service calls methods in components using the rxjs Subject and Observable objects. The target methods then subscribe the returned Observable instance to perform the needed actions. For example, if resetting the pager for any change in searching, sorting, or page resizing after the data access, the code in the ServerPagingComponent could simply be one line (the resetPagerService is the injected instance of the ResetPagerService):

//Call service method to update pager.
pThis.resetPagerService.updatePagerAfterData(pThis.pagingParams, pThis.totalLength);

The code in the ResetPagerService looks like this:

private paginationComponent = new Subject<any>(); 
paginationComponent$ = this.paginationComponent.asObservable();

//Method called from table-hosting component after obtaining data.
updatePagerAfterData(pagingParams: PagingParams, totalLength: number) {        
    - - -    
    this.updatePagerForChangeType(TableChange.search, pagingParams.pageNumber);   
}

//Generic method.
updatePagerForChangeType(changeType: TableChange, pageNumber: number) {
    //Call PaginationComponent to set changeType and run selectPage method.
    let paramesToUpdatePager: ParamsToUpdatePager = {
        changeType: changeType,
        pageNumber: pageNumber
    };
    this.paginationComponent.next(paramesToUpdatePager);
}

The service method then calls the subscribe method in the PaginationComponent:

this.resetPagerService.paginationComponent$.subscribe(
    (paramesToUpdatePager: ParamsToUpdatePager) => {
        if (paramesToUpdatePager.changeType == TableChange.search) {
            this.pagingParams.changeType = TableChange.search;
            this.selectPage(paramesToUpdatePager.pageNumber);
        }
        else {
            if (paramesToUpdatePager.changeType == TableChange.sorting) {
                this.pagingParams.changeType = TableChange.sorting;
                this.selectPage(paramesToUpdatePager.pageNumber);
            }
            else if (paramesToUpdatePager.changeType == TableChange.pageSize) {
                this.setPagerForSizeChange();
            }
        }
    }
);

As a general rule, you need to call the methods of the ResetPagerService in three places of a table-hosting component:

  1. onChangeSearch event handler method called before data access:

    onChangeSearch(searchParams: any) {
        - - -
        this.resetPagerService.setPagingParamsBeforeData(this.pagingParams);
        
        //Call for data here.
    } 
  2. onChangeTable event handler method called before data access:

    onChangeTable(params: PagingParams ):any { 
        - - -
        this.resetPagerService.setPagingParamsBeforeData(this.pagingParams);
        
        //Call for data here.
    }
  3. The place after having obtained data and known the dataset record length:

    //Call service method to update pager. Also need to pass current data length.
    this.resetPagerService.updatePagerAfterData(this.pagingParams, this.totalLength); 

Configurations

The sample application shows two-levels of configurations for using the NgExTable.

  • Library-level: NgExTable/ngex-table.config.ts
  • Consumer-level: NgExTableDemo/app.config.ts

The configuration items are merged in starting AppComponent class by assigning the consumer-level TableConfig object to the library-level NgExTableConfig.base object. If there are the same key names, the consumer-level settings always take precedence.

The code in the app.component.ts:

this.ngExTableConfig.appConfig = TableConfig;

The code in the ngex-table.config.ts:

private _appConfig: any = {};
get appConfig(): any {
    return this._appConfig;
}
set appConfig(v: any) {
    this._appConfig = v;
    this.main = Object.keys(this._appConfig).length ? 
    Object.assign(this.base, this._appConfig) : this.base;
}

Below is the entire TableConfig object for the configurations in the consumer-level. The comment lines illustrate all details of configuration items. Usually, you just need to make any value change in this file if you would like. You don’t have to modify any default setting in the library-level NgExTableConfig class.

//Remove or comment out item for using default setting.
export const TableConfig: any = {    
    pageSize: 10, /* number: number of rows per page */

    toggleWithOriginalDataOrder: true, /*boolean: true: no order, ascending, 
                                         and descending; false: ascending and descending */

    previousText: "&laquo;", /*string: page previous button label. Could be "PREV" */

    nextText: "&raquo;", /*string: page next button label. Could be "NEXT" */

    paginationMaxBlocks: 5, /* number: maximum number of page number buttons if "..." 
                               shown on both sides */

    paginationMinBlocks: 2, /* number: minimum number of page number buttons if "..." 
                               shown on both sides */

    pageNumberWhenPageSizeChange: -1, /*number: 1: reset to first page when changing page size; 
                                       -1: use current pageNumber */

    pageNumberWhenSortingChange: 1, /*number: 1: reset to first page when changing column sorting; 
                                    -1: use current pageNumber */

    sortingWhenPageSizeChange: "current", /*string: "": reset to no order when changing page size; 
                                    "current": use current sorting */
    
    //Related to data search (no default setting in library-level, NgExTableConfig base).
    //-------------------------------
    pageNumberWhenSearchChange: 1, /*number: 1: reset to first page when search or filtering change; 
                                    -1: use current pageNumber */

    sortingWhenSearchChange: "", /*string: "": reset to no order when search or filtering change; 
                                 "current": use current sorting */
    //-------------------------------

    sortingIconLocation: "label-right", /*string: 
          "label-right": sorting icon shown right to column label; 
          "column-right": sorting icon shown by far right side of column */

    sortingIconCssLib: "fa", /*string: "fa", "glyphicon", or custom value */  

    sortingAscIcon: "fa-chevron-up", /*string: "fa-chevron-up" (for "fa"), 
             "glyphicon-triangle-top" (for "glyphicon"), or custom value */

    sortingDescIcon: "fa-chevron-down", /*string: "fa-chevron-down" (for "fa"), 
              "glyphicon-triangle-bottom" (for "glyphicon"), or custom value */ 

    sortingBaseIcon: "fa-sort", /*string: "fa-sort" (for "fa"), "" 
                                  (for "glyphicon"), or custom value */ 

    sortingIconColor: "#c5c5c5" /*string: "#c5c5c5" (default), "#999999", or custom value */    
}

Guidelines for Styles

The NgExTable uses the bootstrap.css version 3.3.7 or above as the base styles for both the grid and pager. It also uses the application-level custom site.css to overwrite some default styles from the bootstrap.css, or add some new styles when needed (see details in the site.css file). In the sample application, those css files are imported to the style.css file defined by the Angular CLI. The style class code with Scalable Vector Graphics (SVG) icon libraries are bundled to the dist directory after the build.

/* You can add global styles to this file, and also import other style files */
@import "~bootstrap/dist/css/bootstrap.css";
@import "~font-awesome/css/font-awesome.css";
@import "assets/site.css";

You can either modify the site.css or add your own global or local (component-level) css files for your needs. The typical example is to set the background color for alternative rows in the table. The bootstrap.css sets the default class and values like this:

.table-striped > tbody > tr:nth-of-type(odd) {
    background-color: #f9f9f9;
}

You can copy this class code to the site.css or local CSS file, and replace the “odd” with the “even” to apply the alternative color to the even numbers of rows. You may change to different color for the alternative rows by modifying the background-color value in the global or local CSS file.

Summary

The NgExTable and the sample application that consumes it presented here are practical and optimal for business web applications, especially those using AngularJS ngTable that would be migrated to the Angular versions. The discussions in this article can help developers better understand the structures and workflow of the NgExTable with the client-side and server-side pagination patterns so that developers can effectively and efficiently incorporate the NgExTable into their own projects. Developers can even add new, and/or modify existing structures to meet their needs. Any feedback from the developer’s communities is welcome.

History

  • 2/7/2018: Original post
  • 12/5/2018: Updated source code, upgraded to Angular 6 CLI setup, added section Reset Pager Service, and edited some other sections

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Shenwei Liu
United States United States
Shenwei is a software developer and architect, and has been working on business applications using Microsoft and Oracle technologies since 1996. He obtained Microsoft Certified Systems Engineer (MCSE) in 1998 and Microsoft Certified Solution Developer (MCSD) in 1999. He has experience in ASP.NET, C#, Visual Basic, Windows and Web Services, Silverlight, WPF, JavaScript/AJAX, HTML, SQL Server, and Oracle.

You may also be interested in...

Comments and Discussions

 
GeneralMessage Closed Pin
5-Dec-18 21:18
memberMember 140794285-Dec-18 21:18 
QuestionThanks anything on Grouping? Pin
gudimanojreddy24-May-18 11:09
membergudimanojreddy24-May-18 11:09 
AnswerRe: Thanks anything on Grouping? Pin
Shenwei Liu5-Dec-18 18:55
memberShenwei Liu5-Dec-18 18:55 
PraiseClient and Server-Side Data Filtering, Sorting, and Pagination with Angular NgExTable Pin
Member 1368468318-Feb-18 21:01
memberMember 1368468318-Feb-18 21:01 
QuestionMessage Closed Pin
7-Feb-18 19:45
memberLaxman Pratap7-Feb-18 19:45 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web06 | 2.8.181215.1 | Last Updated 5 Dec 2018
Article Copyright 2018 by Shenwei Liu
Everything else Copyright © CodeProject, 1999-2018
Layout: fixed | fluid