Widgets
Tables
Shpadoinkle-widgets
offers a feature-rich table widget. It features:
-
The ability to fully customize the styling
-
Generic handling of turning tabular data into an HTML table
-
Generic handling of data filters
-
Generic handling of sorting data by any column (in ascending or descending order)
-
Built-in and customizable interactions with table headers to sort by a column
-
Generic handling of lazily loaded and lazily populated tables with infinite scroll
All of this is achieved using only a few hundred lines of code, located in the modules Shpadoinkle.Widgets.Table
and Shpadoinkle.Widgets.Table.Lazy
.
A "lazily loaded table," for our purposes, is one where the data is not all loaded up front but rather is loaded as the user interacts with the table, using an infinite scrolling experience.
A "lazily populated table," for our purposes, is one where the DOM nodes for table rows are not necessarily created up front, but rather may be created on demand as the user scrolls. A lazily populated table may or may not be lazily loaded.
Both lazily loaded tables and lazily populated tables are handled using Shpadoinkle.Widgets.Table.Lazy
.
Let’s start with the case of a regular non-lazy table. This case depends on a few concrete types:
Sort
represents a sort order (ascending or descending).
SortCol
represents a way of sorting the table. It consists of a column and a sort order.
Column
and Row
are data families. The user of the table widget must instantiate each of these data families for their use case. Column
should be an enumerable data type with a finite number of cases, one for each column of the table. Row
should be a view model for a table row.
Column
and Row
are parameterized by a single type a
. This type a
is a view model for a table, almost. The type a
will be supplemented with some data which the table widget uses to maintain its state, in order to create a full view model for a table. This is done by putting a
as an element of a tuple.
For a basic table widget, a view model consists of a
and a SortCol a
. A SortCol a
consists of a Column a
and a Sort
. So a view model for a basic table widget is a tuple (a, SortCol a)
. For a lazy table widget, there is some more data that gets added onto the view model.
The type a
must be an instance of the Tabular
type class. This type class explains to the table widget module how to render the table and create the basic user interactions. It has the following pieces:
-
A constraint family
Effect a
which takes a type of kind* → *
(in practice, a monad). This puts the constraints on the monad where event handlers run which those event handlers require in order to do their work. By default,Effect a m
is a constraint which requires thatm
isApplicative
. -
A function
toRows :: a → [Row a]
which extracts the rows from the table data. -
A function
toFilter :: a → (Row a → Bool)
which tells the table widget how to filter rows. This implies that the state of the table filters (if any) must be ona
. The default implementation oftoFilter
does not filter out any rows. -
A function
toCell
which tells the table widget how to render the contents of a cell. -
A function
sortTable
which tells the table widget how to compare two rows to say which should come first based on the sort order. -
A view
ascendingIcon
which tells the table widget how to render an icon indicating that a certain column is the one we are sorting by and the sort is in ascending order. By default this is an arrow pointing upwards rendered in Unicode. -
A view
descendingIcon
which tells the table widget how to render an icon indicating that a certain column is the one we are sorting by and the sort is in descending order. By default this is an arrow pointing downwards rendered in Unicode. -
A function
handleSort
which gives an action (a continuation) to perform when the user changes the sort order. By default it doesn’t do anyting. In that case, the table widget still knows how to re-sort the rows and it will do so.
The table widget requires some additional information to know how to render the table, which is provided in the form of a Theme
. Theme
is a data type which is parameterized by m
(the monad where event handlers run) and a
(the table data type). The Theme
can be used to attach arbitrary properties to the <table
, <thead>
, <tbody>
, <th>
, <tr>
, and <td>
elements generated by the table widget.
If you do not require any special properties on the aforementioned elements, then you can create a basic table widget using Shpadoinkle.Widgets.Table.view
, which will use a default theme that does not attach any properties to the aforementioned elements beyond those required to make the table widget function with its default behavior. This function expects an a
(the table data) and a sort order (a SortCol a
) and it gives you a view with the view model type Html m (a, SortCol a)
.
If you do need to provide a non-default Theme
, then you can create a basic table widget using Shpadoinkle.Widgets.Table.viewWith
, which expects (in addition to an a
and a SortCol a
), a Theme m a
.
Lazy tables
To create a lazy table, you will need to import both Shpadoinkle.Widgets.Table
and Shpadoinkle.Widgets.Table.Lazy
. The latter is a special application of the former.
The lazy table module introduces a subclass of Tabular
, called LazyTabular
. LazyTabular
has a method, countRows
, which tells the lazy table widget how to know how many rows are in a table. For a basic table widget, we can count the rows by doing length . toRows
, but this does not necessarily work for a lazy table widget, because some of the rows might be on the backend (not yet fetched to the client side). It also may be beneficial to avoid computing length . toRows
on a lazily populated table where all the rows are in memory, because taking the length of a long list can be an expensive operation. Thus it is up to the user of the lazy table widget to say how to determine the number of rows.
The number of rows is used to set the size of the scroll bar. There may be a very empty row at the end of the table if the number of rows rendered in the DOM is less than the number output by countRows
. This is done in order to give the user an accurate general idea of how much data is in the table, so they know that if they scroll down to a certain depth, they may see data, and they can do so by dragging the scroll bar.
The lazy table widget wraps the table view model type a
in a data type LazyTable a
, in order to hold state which it needs in order to function. This LazyTable
data type does not escape into end user code. The LazyTable
data type can be regenerated given certain information passed to the lazy table widget along with certain information which must be stored on the end user’s view model. What information this is exactly depends on what kind of lazy table we are talking about.
A lazily populated table (not a lazy loading table) is constructed using the lazyTable
function, which outputs a view of type Html m (b, CurrentScrollY)
. We will get back to b
momentarily. The additional information which the lazily populated table needs to put on the user’s view model is CurrentScrollY
, which tracks the number of pixels the user has scrolled down in the table.
A lazily loaded table (which may also be a lazily populated table) is constructed using the lazyLoadingTable
function, which outputs a view of type Html m (b, CurrentScrollY, RowsLoaded)
. The additional information which the lazily loaded table needs to put on the user’s view model in addition to CurrentScrollY
is RowsLoaded
, which tracks the number of rows that have been loaded so far.
Both varieties of lazy table widget need the following data in addition to the data required by the basic table widget:
-
The
AssumedTableHeight
, which is the number of pixels tall the whole table is. This is either the height of the table body, or the height of the container, depending on the setting ofLazyTableScrollConfig
. -
The
AssumedRowHeight
, which is the number of pixels tall a row of data is. This is assumed to be the same for all rows, so that the trick of extending the scroll bar can work. -
The
LazyTableScrollConfig
. This tells the widget whether you want the container of the table to scroll, or the body of the table. If you let the container scroll, then the table headers will scroll out of view. If you let the body scroll, the the table headers will not scroll out of view. In addition, theLazyTableScrollConfig
supplies the table widget with aDebounce
. TheDebounce
lets the table widget debounce the scroll events at an approriate tempo (which you can find for your app using testing). Debouncing the scroll events is necessary in order to avoid performance problems when scrolling the table. -
The container. For a lazily populated table, it is of type
Html m ((a, SortCol a), CurrentScrollY) → Html m ((b, SortCol a), CurrentScrollY)
. For a lazy loading table, it is of typeHtml m ((b, SortCol a), CurrentScrollY, RowsLoaded)
. The container wraps around the<table>
element. If you do not need a container, then you can set it toid
. It is provided so that it can be the thing which scrolls in the lazy table interaction. This is whereb
comes in the type of the resulting view. The container is allowed to change the type of the view model, though it is not allowed to remove the data which the basic table widget and the lazy table widget require. -
The
CurrentScrollY
must also be supplied.
For a lazy loading table, the following additional data must be supplied:
-
The
RowsLoaded
. -
The
Paginator
. ThePaginator
tells the table widget how to load more data.
Paginator a
is a newtype
of forall m. ( Applicative m, Effect a m ) ⇒ a → SortCol a → Page → m a
. Page
is a data type representing a page of data, consisting of an Offset
(a zero-based index into the whole list of rows, after applying the filters and sort order), and a Length
(a number of rows). Given the current table view model a
, and the sort order and a page, the Paginator
must return a new table view model which includes the data in the given Page
(as well as any data that is already there which is outside of the given Page
).
The lazy loading table widget handles knowing which pages of data to load when as the end user scrolls, without reloading data that is already there. When the end user changes the sort order, the widget knows to fetch a page of data which has an offset of zero and goes as far as the end user has scrolled. It is up to the user of the widget to load new data when the end user changes the filters, because the controls for any filters are not part of the table widget.