Platform: Datatable Technical Design - SAP/fundamental-ngx GitHub Wiki

Datatable

Datatable

Summary

Datatable (DT) implements data grid that is used to render tabular-like data.

Existing @fundamental-ngx/core datatable implementation is based on plain HTML table with set of directives which is good building block to build more advanced structure to support enterprise use-cases.

This documents focuses not only on the component signature but also on the design principles centered around this complex component.

DT Implementation is going to be centered around columns which is completly different concept than any other implementations such as PrimeNG or Angular Material. Treating everything as column gives us great way to render any kinds features.

Existing core implementation might looks like this:

<table fd-table>
    <thead fd-table-header>
        <tr fd-table-row>
            <th fd-table-cell fdColumnSortable [sortDir]="column1SortDir" (click)="sortColumn1()">Column 1</th>
            <th fd-table-cell>Column 2</th>
            <th fd-table-cell>Column 3</th>
            <th fd-table-cell fdColumnSortable [sortDir]="dateSortDir" (click)="sortDate()">Date</th>
            <th fd-table-cell>Type</th>
        </tr>
    </thead>
    <tbody fd-table-body>
        <tr *ngFor="let row of tableRows" fd-table-row>
            <td fd-table-cell class="fd-has-font-weight-semi"><a href="#">{{row.column1}}</a></td>
            <td fd-table-cell>{{row.column2}}</td>
            <td fd-table-cell>{{row.column3}}</td>
            <td fd-table-cell>{{row.date}}</td>
            <td fd-table-cell><fd-icon [glyph]="row.type"></fd-icon></td>
        </tr>
    </tbody>
</table>

which let's you define basic structure and this we are going to extends in Platform implementation. It will consists from set of high-order components which makes the composition easy even for more complex use cases. Since datatable needs to work also with large datasets we also need to introduce concept called DataSource.

1. Simple table

To create simple table that is interpolating read only values we can use something like this.

<fdp-datatable  [datasource]="users" >
    <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
    <fdp-dt-column key="lastName" label="Last Name" > </fdp-dt-column>
    <fdp-dt-column key="department" label="Department" > </fdp-dt-column>
    <fdp-dt-column key="email" label="Email" > </fdp-dt-column>
</fdp-datatable>

Bindings:

  • datasource : Provides state and component model. DT datasource should look like this
  type FdpDataTableDataSource<T> = DataTableDataSource<T> | T[];

It should be able to work both with array and specific datasource implementations.

2. Simple table with selection column

Extends above DT to show selection column

<fdp-datatable  [datasource]="users" [showSelectionColumn]="true">
    <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
    <fdp-dt-column key="lastName" label="Last Name" > </fdp-dt-column>
    <fdp-dt-column key="department" label="Department" > </fdp-dt-column>
    <fdp-dt-column key="email" label="Email" > </fdp-dt-column>
</fdp-datatable>

Bindings:

  • showSelectionColumn : Turns on/off selection mode. This bindings goes side by side with the selection mode that can be set. Default implementaiton should set none
<fdp-datatable  [datasource]="users" [showSelectionColumn]="true" [selectionMode]='single'>
  • selectionMode: Sets selection mode characteristics for the datatable. posible values are:

        export type SelectionMode = 'multi' | 'single' | 'cell' | 'none';   

    types are self explenatory

  • showSelectAll: Displays the select all checkbox in case of multiselection

3. Simple table with different column options

Table columns can accept variety of inputs, to align, make the column visible or even sortable.

 <fdp-dt-column key="firstName" 
                label="First Name" 
                align="left" 
                isVisible="false" 
                sortable="true" 
                sortOrdering="descending" 
                isDraggable="true"> 
</fdp-dt-column>

Bindings:

  • key: What field name to read from the given object
  • label: Column header label or we can use Or you can use headerTemplate to define your own template
  • align: Cell alignment. It inserts regular align attribute to the table cell
  • isVisible: If false applies a class style that hides the column
  • sortable: Marks column as sortable which means sorting icon is added to the header with special sorting handling
  • sortOrdering: Sorting direction
  • isDraggable: Main column header if we allow dragging by showing dragging handle

4. Detail row

To create expandable detail row you provide one single template fdp-dt-detail-column which takes into account current column to render different content. For each of the row it provides collapsible control to show or hide the detail row.

<fdp-datatable  [datasource]="users"  showRowDetailExpansionControl="false">
    <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
    <fdp-dt-column key="lastName" label="Last Name" > </fdp-dt-column>
    <fdp-dt-column key="department" label="Department" > </fdp-dt-column>
    <fdp-dt-column key="email" label="Email" > </fdp-dt-column2>

    <fdp-dt-detail-column >
        <ng-template #body let-colum let-item="rowData">
              ....
        </ng-template>
    </fdp-dt-detail-column>
</fdp-datatable>

Bindings:

  • showRowDetailExpansionControl: Render or hide expansion control for row detail columns. Expansion control makes sense for simple table, when using this inside outline (tree table), its driven by outline control

5. Data based on dataType

Just like it's mentioned in DataSource document above, it should be possible to also initialize data based on the domain class type. On the application level we register different providers based on TYPE and datatable internally look up the registery retrieve right DataSource and initialize dataSource internally.

<fdp-datatable  [entityClass]="'User'" [pageSize]="15" emptyMessage="No records found">
    <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
    <fdp-dt-column key="lastName" label="Last Name" > </fdp-dt-column>
    <fdp-dt-column key="department" label="Department" > </fdp-dt-column>
    <fdp-dt-column key="email" label="Email" > </fdp-dt-column2>
</fdp-datatable>

Bindings:

  • pageSize: Used for paging or lazy virtual scrolling to set initial fetch limit size
  • emptyMessage: Default message when there are no data.
    • we should also provide a ng-template
  • entityClass: Name of the entity for which DataProvider will be loaded.
    • additionaly we could set a [entityClassPredicate]="map with key values" to set additional query parameters

6. Set initial sorting Key and sorting order on table level

<fdp-datatable  [entityClass]="'User'" initialSortKey="firstName" initialSortOrder="descending">
    <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
    <fdp-dt-column key="lastName" label="Last Name" > </fdp-dt-column>
    <fdp-dt-column key="department" label="Department" > </fdp-dt-column>
    <fdp-dt-column key="email" label="Email" > </fdp-dt-column2>
</fdp-datatable>

Bindings:

  • initialSortKey: What column is used as first for the sorting
  • initialSortOrder: Allow to change sorting direction

Both datatable as well as column should allow to pass additional styling classes so DT can be stylable on every level.

  • pageSize: Used for paging to set initial page size
  • displayRowSize: When virtual scrolling is used sets visible scrolling limit.

Events:

  • onRowClick: Based on selection mode it triggers event for the rows selected
  • onRowSelectionChange: When multi or single selection mode is enabled it will trigger event when checkbox o radio buttons is selected
  • onCellChange: When cell body selection changes we fire event
  • onHeaderSelection: When cell header selection changes we fire event

7. Tree table

Three table will let us to display data in hierarchical order (shown above) and to do that the DT needs to be pretty generic. There are two ways how to go around it:

  1. Use one of the column for tree control. We have also build pretty flexible tree control that can be applied over any component that requires this outline-like functionality

  2. Treat the same way like we work with single, multi selection column( to insert it automatically).

<fdp-datatable  [datasource]="usersTree" [outlineFormat]="'tree'" [pivotalLayout]="true" >
    <fdp-dt-column key="ID" >
        <ng-template #body let-colum let-item="rowData">
            <fdp-outline-control #outlineCtrl>
                {{item.id}}
            </fdp-outline-control>
        </ng-template> 
    </fdp-dt-column>
    <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
    <fdp-dt-column key="lastName" label="Last Name" > </fdp-dt-column>
    <fdp-dt-column key="department" label="Department" > </fdp-dt-column>
    <fdp-dt-column key="email" label="Email" > </fdp-dt-column>
</fdp-datatable>

By adding outline control to the first column we enable table to support tree. When using outline format=tree, we expect application will provide whole tree structure including children

The other approach does this more lazily and its more flexible in terms of how you provide a data.There is a callback to get children every time you try to expand each level.

<fdp-datatable  [datasource]="usersTree" [pivotalLayout]="true" [children]="children"
    pushRootSectionOnNewLine="true">
    <fdp-dt-column key="ID" >
        <ng-template #body let-colum let-item="rowData">
            <fdp-outline-control #outlineCtrl>
                {{item.id}}
            </fdp-outline-control>
        </ng-template> 
    </fdp-dt-column>
    <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
    <fdp-dt-column key="lastName" label="Last Name" > </fdp-dt-column>
    <fdp-dt-column key="department" label="Department" > </fdp-dt-column>
    <fdp-dt-column key="email" label="Email" > </fdp-dt-column>
</fdp-datatable>

Bindings:

  • pivotalLayout: When active applies special styles to the DT. Later on once pivot is implemented this will also add additional behavior to the DT
  • outlineFormat: Outline should supports two modes free, where application is responsible to retrieve children for each node and tree with specific OutlineNode structure
  • children: Custom method provided by application to retrieve list of children for current item. If children is undefined then, default 'children' field is used .children
  • disableParentSelection: In Case if all the Children under Parent are selected, should be the parent also selected
  • pushRootSectionOnNewLine: Pushes node section on the new line
  • indentationPerLevel: You can change default indentation for the outline nodes
  export type ModelFormat = 'free' | 'tree';
export interface OutlineNode extends Identity
{
    /**
     * Reference to parent node.
     */
    parent: OutlineNode;

    /**
     * Node's children. Even its a field it can be implemented lazily using getter where a target
     * object does not implement this as a public field but a getter with control over the
     * retrieved list
     */
    children: OutlineNode[];

    /**
     * Different states for outline Node
     *
     * isExpanded: boolean;= moving out as this is managed by expansionstate.
     */
    isExpanded: boolean;
    isSelected: boolean;
    isMatch?: boolean;
    readonly?: boolean;
    type?: string;
    draggable?: boolean;
    droppable?: boolean;
    visible?: boolean;

}

8. Grouping

Grouping enables DT to aggregate information by selected column(s). Grouping should support nesting as well.

Datatable

Not like a tree structure grouping is more complex topic and therefore in this sections we focus on usage from application point of view and in the Datatable internal design we should be able to highlight some ideas how to implement this.

In general this functionality should work in two modes. We need to be able to group data in memory in case we are using e.g. ArrayDataProvider (Data provider that works with local array and does not communicate with backend) or group data on the backend.

Since Grouping involves more work we need to introduce new Specific GroupingAPI which is going to be first ground work to be closer to the Pivot table support.

In grouping we compute a set of unique table columns for each unique combination of Column Field values ( Here we need to introduce a let's call it: EdgeCell to represent a tree) and via the EdgeCell incorporates these columns in the DataTable's displayedColumns set.

Pivot mode is nothing else than rendering a multi-dimensional data set along two dimensions: rows and columns and this is ultimately a form of nested grouping with objects sorted in the order of their left-hand side levels (Row Fields) and then their top to bottom levels (Column Fields).

<fdp-datatable  [datasource]="usersTree" [pivotalLayout]="true" 
    [groupByColumns]="columnFields"
    [showGroupCounts]="false" >
    <fdp-dt-column key="ID" > </fdp-dt-column>
    <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
    <fdp-dt-column key="lastName" label="Last Name" > </fdp-dt-column>
    <fdp-dt-column key="department" label="Department" > </fdp-dt-column>
    <fdp-dt-column key="email" label="Email" > </fdp-dt-column>
</fdp-datatable>

Bindings:

  • groupByColumns: Initialize table with array of fields to group on.
    • When this bindings is used pivotalLayout should be set to TRUE
  • showGroupCounts: Tells if we need to display count aggregation.

9. Extensibility Support

Since DT does not implement only Read-only data we need to be able to provide a support to extends and modify this table on several levels:

  • Content Templating
  • Styling

Content Templating

As I have mentioned above the <fdp-dt-column> has two parts the one that renders the header and another one for body. To be able to add custom content to it we need to use ng-template.

<fdp-dt-column key="ID" >
    <ng-template #header let-column let-rowData="rowData">
                This is my Custom Header with 3 lines and some picture
    </ng-template>
 
    <ng-template #body let-column let-rowData="rowData">
            My Custom content with <fdp-select></fdp-select> input
    </ng-template>

</fdp-dt-column>

To remove the repetition in case column definition would be identical for every column we need to be able to define the template once one DT level so it can be applied to each column.

<fdp-datatable  >
    <fdp-dt-column key="ID" > </fdp-dt-column>
    <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
    <fdp-dt-column key="lastName" label="Last Name" > </fdp-dt-column>    
    <fdp-dt-column key="email" label="Email" > </fdp-dt-column>
    <fdp-dt-column key="department" label="Department" >
         <ng-template #body let-column let-rowData="rowData">
                    My Custom content with <fdp-select></fdp-select> input
          </ng-template>
    </fdp-dt-column>
    
    
        <!-- Global templates for all columns, unless column defines its own body/header template to override this -->
        <ng-template #dtBody let-column let-rowData="rowData">
            default body
        </ng-template>
    
        <ng-template #dtHeader let-column let-rowData="rowData">
            Default header             
        </ng-template>    
</fdp-datatable>

Styling

DT needs to allow application developer to adjust some visual aspect it using CSS. Todo this we need to expose also expose styling inputs

<fdp-datatable  tableStyleClass="my-custom-template"
      
>
    <fdp-dt-column key="email" 
            [dataStyleClass]="xxxx"
            [headerStyleClass]="xxxx"
            [bodyClassFn]="Dynamic class based on type or value"  
            
> 
    </fdp-dt-column>
</fdp-datatable>

Bindings:

  • tableStyleClass: Used by DT Wrapper to add app class into the table tag
  • headerStyleClass: Default static class that is added to the TH into the header.
  • dataStyleClass: Default static class that is added to the td into the body.
  • bodyClassFn: Retrieves dynamic class based on data and then its added to the table cell TD
    • We have agreed that we will try to avoid callback functions but this this case I could not find better way to do it.

9. Drag & Drop Support

Support for D&D, means in this context reordering of rows as well as columns, including changing column width by dragging its right edge. To make the D&D support easier we should utilize @Angular/cdk support.

<fdp-datatable [dndColumnEnabled]="true" [dndRowEnabled]="true" 
               (dndDragRowStart)="onStart($event)"
               (dndDropRow)="onDrop($event)"
                [dndAltKeyEnabled]="true"                
>
    <fdp-dt-column key="ID" [isDraggable]="true" isDroppable="true"> </fdp-dt-column>
</fdp-datatable>

Bindings:

  • dndColumnEnabled: Enables or disables column reordering
  • dndRowEnabled: Enables or disables row reordering
  • dndDragRowStart: onDragStartEvent is called to set data for D&D.
  • dndDropRow: D&D onDropEvent is called to persist the reordering of rows.
  • dndAltKeyEnabled: Turn on ALT key for drag and drop. When true only with ALT D&D can be possible

Column:

  • isDraggable: Tell the main column header if we allow dragging by showing dragging handle
  • isDroppable: When working with Tree and Pivotal like layout we could have some hidden column (they could be styled this way) on purpose and we need to make sure not to allow dropping

Notes : My original implementation of the data table had all the features built-in, all the different columns, support for tree table, Pivotal (analytical) layouts, but what if each of these features will be implemented by set of directives. Each directive adds different set of behavior and the main datatable can be pretty light.

I think it should be possible to add directive conditionally and apply different set of behavior.


Datatable internal design

As already mentioned above the internal implementation is going to be based on columns. What does it means for us is to have differents set of column's type implementations that we can plugin to enable different behaviors. Different types of column could be:

  • SingleSelection Column
  • MultiSelection Column
  • Detail Row Colum
  • Groupping Column
  • Pivotal Columns

Each column internal implementation have two main parts: Header and Body. Default implementations should be capable extract a value from current context and interpolate it to the body area.

   <fdp-dt-column key="firstName" label="First Name" > </fdp-dt-column>
  • key: maps to Object field name

Or each of these areas should support templating so on the application can provide its own content:

   <fdp-dt-column key="firstName" label="First Name" > 
     <ng-template #body let-colum let-item="rowData">
          <span class="fancyCell">       
              {{item.firstName}}
          </span>
        </ng-template>
    </fdp-dt-column>
  • item(rowData): Current Object rendered for a row
  • column: Should refer to current column instance

Regular Column

regular column

Data are rendered column by column, header -> body. Depending if selection needs to be supported selection column is rendered as first one.

Detail row Column

regular column

Details column provides expandable functionality and render one column across whole datatable.

Pivotal row column

regular column

Since pivot is nothing else than connected directional graph with 1 root and the edges are implemented by columns with variable column span pivotal structure should provide this functionality

High level component structure

Following above DT signature, where we defined <fdp-datatable> tag followed by column definition <fdp-dt-column>, the datatable will have 3 main parts:

  • TableWrapper Component
  • Datatable Component
  • DT Column component(s)

TableWrapper is responsible for laying out header, data(rows), footer. This wrapper implements virtual scrolling as well as pagging functionality.

<fdp-dt-wrapper #dtWrapper>
    <ng-template #headingArea>
        <ng-content select="fdp-dt-header"></ng-content>
    </ng-template>

    <ng-template #headerRows let-colsToRender >
        <ng-container *ngTemplateOutlet="header; context:{$implicit: colsToRender }">
        </ng-container>
    </ng-template>

    <ng-template #bodyRows let-colsToRender>
       <ng-container *ngTemplateOutlet="body; context:{$implicit: colsToRender}"></ng-container>
    </ng-template>
</fdp--dt-wrapper>

This goes to the main fdp-datatable.component.ts html template. Wrapper uses ng-template to layout the main sections of the components. When rendering each row:

  <ng-container *ngTemplateOutlet="body; context:{$implicit: colsToRender}"></ng-container>

the body TemplateOutlet iterates thru columns and iterates each one by one from 1st to last. The body rendering part could look like this:

<ng-template #body let-colsToRender>

    <tbody>
    <ng-template ngFor let-rowData [ngForOf]="dataToRender" let-even="even" let-odd="odd"
                 let-rowIndex="index" [ngForTrackBy]="rowTrackBy">
        <ng-container *ngTemplateOutlet="rowTemplate; context:{$implicit: rowData, even:even,
                                          odd:odd, rowIndex:rowIndex, colsToRender:colsToRender}">
        </ng-container>      
    </ng-template>
    </tbody>
</ng-template>


<ng-template #rowTemplate let-rowData let-even="event" let-odd="odd" let-rowIndex="rowIndex"
             let-nestingLevel="nestingLevel" let-colsToRender="colsToRender">


    <tr #rowElement (click)="handleRowClickIfEnabled($event, rowData)">
        <ng-template ngFor let-col [ngForOf]="colsToRender" let-colIndex="index">

            <ng-container *ngTemplateOutlet="col.rendererTemplate;
                    context:{$implicit: false, data:rowData, rowIndex:rowIndex,columnIndex: colIndex}">
            </ng-container>
        </ng-template>
    </tr>
</ng-template>

The important part to note we are using the same provent technique that is also used for the Form Layout, where the html template is wrapped with ng-template to prevent unwanted rendering.

The column template definition looks like this:

<ng-template #renderingTemplate let-column="column" let-dataToRender="data"
             let-localColumnIndex="localColumnIndex"
             let-columnIndex="columnIndex"
             let-rowIndex="rowIndex">

    <ng-template *ngIf="isHeader" [ngTemplateOutlet]="colHeader"
                 [ngTemplateOutletContext]="{$implicit....}">
    </ng-template>

    <ng-template *ngIf="!isHeader" [ngTemplateOutlet]="colBody"
                 [ngTemplateOutletContext]="{$implicit: co...">
    </ng-template>
</ng-template>

As you can see the column content is wrapped with renderingTemplate which we call when iterating over the columns. as you can see in above example. Then the main content of the column might look like this:

<ng-template #colBody let-data="data" let-rowIndex="rowIndex">

    <td #cell (click)="dt.onCellSelectionChange(cell, this, data)"    >

         ....
        <ng-container *ngTemplateOutlet="bodyCell">
        </ng-container>
        ...
    </td>
</ng-template>


<ng-template #bodyCell let-data="data" let-rowIndex="rowIndex">
       <!--
           when no template is used use our FieldPath to access the object value based on the
           key binding and interporate result
        -->
        <span class="dt-col-cell-data" *ngIf="!bodyTemplate">
            {{dt.getValue(data, key)}}
        </span>

    <!--
        In case application wants to provide their own cell component they use
        #body ng-template to do so.
    -->
    <span class="dt-col-cell-data" *ngIf="bodyTemplate">
            <ng-container *ngTemplateOutlet="bodyTemplate;
            context: {$implicit: this, rowData: data, rowIndex: rowIndex}"></ng-container>
        </span>
</ng-template>

Composition of above components always starts from Table -> TableWrapper -> TableBody (also header, footer) -> TableRow -> TableColumn -> TableCell.

In above fragment we are delegating column rendering to its specific implementation - col.rendererTemplate;. DT is not aware what is rendered, this is way we are able to set different column implementations

regular column


i18n

Link to general support for i18n: Supporting internationalization in ngx/platform

Special Usecase: Yes

There are some default values that are assigned in the lib which need to be translated, such as emptyMessage which can have a default value(though not specified in this spec) or some pagination messages like "Page 2 of 30"

  • fdp-dt-column can be supported as:
    <fdp-dt-column key="firstName" i18n-label="@@firstName" label="First Name" > </fdp-dt-column>
  • emptyMessage when provided, can be marked with i18n marker normally:
<fdp-datatable [entityClass]="'User'" [pageSize]="15" i18n-emptyMessage="@@empty" emptyMessage="No records found">
...
</fdp-datatable>
  • For Detail Row, i18n markers can be placed in the ng-template:
    <fdp-dt-detail-column >
        <ng-template #body let-colum let-item="rowData">
              ....
        </ng-template>
    </fdp-dt-detail-column>

Redesign Required: No

Declarative approach is supported by allowing user to use ng-template and defining i18n markers for the DT content:

<fdp-dt-column key="ID" >
    <ng-template #header let-column let-rowData="rowData">
               <span i18n="@@headerContent"> This is my Custom Header with 3 lines and some picture</span>
    </ng-template>
 
    <ng-template #body let-column let-rowData="rowData">
           <span i18n="@@bodyContent">  My Custom content with <fdp-select></fdp-select> input </span>
    </ng-template>
</fdp-dt-column>

Notes

Should this be also input control and implement FormControlField interface?

The FormFieldControl as described in the FormGroup Layout or extend existing BaseInputComponent.

⚠️ **GitHub.com Fallback** ⚠️