Platform: Datatable Technical Design - SAP/fundamental-ngx GitHub Wiki
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.
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.
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
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
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
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
- we should also provide a
-
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
- additionaly we could set a
<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
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:
-
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
-
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 andtree
with specificOutlineNode
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;
}
Grouping enables DT to aggregate information by selected column(s). Grouping should support nesting as well.
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.
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
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>
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.
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.
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
Data are rendered column by column, header -> body. Depending if selection needs to be supported selection column is rendered as first one.
Details column provides expandable functionality and render one column across whole datatable.
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
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
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>
Should this be also input control and implement FormControlField interface?
The FormFieldControl
as described in the FormGroup Layout or extend existing BaseInputComponent.