The aim is to have an extendable data grid with separated concerns and avoid too much intertwined code.
So we define in this ticket the basic internal architecture for the future OnyxDataGrid.
Description
We begin by defining what table state is: Everything in our data grid that can change over time.
So what can change?
ā order (sorting, drag n'drop)
ā existence (filtering, deletion, creation, pagination)
ā input values (:tableData="tableData")
ā input metadata ("highlighted")
ā changed values ("edits")
ā internal metadata ("checked")
What is not considered state?
- ā ongoing user interactions, e.g. [composition](https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent) or when the user is dragging an element, but hasn't dropped it yet
=> state is the sum of these data
or
"State is a function of data and context"
or
$State = f(Data, Context)$
Implementation
Technically, we will achieve this using three layers:
TableApiLayer => provides a neat API for the users and hides the internal logic
TableLogicLayer => holds all the logic and creates an immutable state object (type of RenderTable) representing the table (our "State Function")
TableRenderLayer => uses the state object to render the table.
1. TableApiLayer
TBD - not relevant for this PoC
type OnyxDataGridProps<TableData> = {
columnDefs: {
key: keyof TableData;
/**
* Provide your custom render Component.
* The value of the cell will be per `modelValue` prop.
*/
customRenderer: Component<{ modelValue: TableData[keyof TableData] }>;
}
};
2. TableLogicLayer
ā¹ļø For the following examples and definitions, we assume a table is rendered out of the following, very simple API:
type SimpleHeader = {
label: string;
}
type SimpleRow = {
value: string;
}
type SimpleTableAPI = {
headers: SimpleHeader[];
rows: SimpleRow[][];
}
In this layer, we take the provided user table data and transform and extend it.
Every feature acts as a transform with the following signature: function enrichTable(userTableData: SimpleTableAPI): SimpleTableAPI.
Ideally, we implement our features with the same API that we provide for others ("Eat your own dog food").
We can optimize the transformation process by taking a look at the aspects of a table feature:
adding/changing/removing row data
adding/changing/removing column definitions
change how cells are rendered
change how headers are rendered
adding/changing/removing row data
We can imagine features which mutate row data as a sequence of array mutations:
E.g. :
Add user defined rows (which were added through clicking "add row")
Add virtual rows (load more...)
Filter user deleted rows
Filter user filtered rows (when user has defined a filter)
Mark user selected rows
Apply user sorting.
This can be abstracted into the following steps:
Array mutations before
Array mutation (map)
Map entries before
Map entries
Map entries after
Array mutations after
By grouping all mappings together, we can optimize the performance.
Adding/changing/removing column definitions
There is no need for optimizations here, as we can assume the rendered columns count to be a low.
Therefore, we can just provide a simple mapping function.
Change how cells/headers/rows are rendered
For rendering, we make use of Vue's FunctionalComponents.
These are always stateless, which is what we want for performance and philosophy reasons.
The component must always take a Header component as a prop and wrap/use it.
The TableRenderLayer must be stateless.
Its main purpose is to create an HTML compliant table structure.
The table should be rendered according to the following rules:
columns describes the horizontal layout of the table, every entry MUST result in a column and <th>element
rows describe the vertical layout of the table, every entry MUST result in a row and a <tr> element
so there should not be any "unused" entries in either column or row
extensions should only follow table concepts that are supported/specified by the HTML spec
the entry keys of a row must match the specified columns or colgroups
the content of the table data cells (<td>...</td>) in a row can be any content
/** API */
/**
* Props of the TableRenderLayer
*/
type RenderTable<TableEntry extends object> = {
idName: keyof TableEntry;
columns: RenderColumn<TableEntry>[];
rows: RenderRow<TableEntry>[];
}
type RenderColumn<Key extends keyof TableEntry | symbol> = {
/**
* Key of the column - usually a key of the tabledata.
* But can also be used for custom columns.
*/
key: Key;
/**
* Attributes and data that is provided to the component using `v-bind`.
*/
cellAttrs?: TdHTMLAttributes;
/**
* The component that renders the header content.
*/
header: FunctionalComponent;
}
type RenderCell<Key extends keyof TableEntry, CellData extends TableEntry[Key]> = {
/**
* Key of the column - usually a key of the tabledata.
* But can also be used for custom columns.
*/
key: Key;
/**
* Data that is provided to the component using via the `metadata` prop
*/
metadata?: object;
/**
* table data that is provided to the component using via the `metadata` prop
*/
data: CellData;
/**
* The component that renders the actual cell content.
*/
cell: FunctionalComponent;
}
type RenderRow<IdKey extends keyof TableEntry, RowId extends TableEntry[IdKey]> = {
/**
* Unique id of the row.
*/
id: RowId;
/**
* Data that is provided to the row component using via the `metadata` prop
*/
metadata?: object;
entries: RenderCell[];
/**
* can be used to add attributes, including class, style and even event listeners
*/
rowAttrs: HTMLAttributes;
}
Thanks @BoppLi for challenging the RFC:
bullet points of the challenges:
column config?
e.g. reordering the columns
e.g. column resizing
lazy rendering? -> virtual scrolling support
slots for cells?
pinned rows / columns?
are we sure that re-renderings of the pipeline will not accidentally "kill" e.g. > unsaved changes?
interference of e.g. "active page" in pagination vs filterValue changes
how does saving changes affect the pipeline? the "temporary state" would be dissolved > and the prop data would change, how to make sure those don't collide?
-> who owns which state?
any forseeable issues with keyboard navigation?
how are events bubbled up? we had immense problems in CoreUi with that where we had to > pass them on manually (edit mode), and with tooltips, it did not even work at all
style definitions e.g. on columns, like "monospace", alignment, truncation, ...
Solutions
Column configuration is provided similar to how the API is currently in CoreUI. The user passes the config in through the TableApiLayer.
Reordering the columns should be doable through the TableFeatureAPI:
adding the drag element can be done through modifyColumns
the drag element can listen for dragstart
modifyColumns can also be used to add virtual columns that can be styled and used as drop targets
For resizing columns modifyColumns can also be used to add virtual columns that can be styled as resize borders and that have event listeners
Lazy rendering is such a foundational feature that it would also be fine to implement this in the Render layer as a base feature.
But it would be possible to implement this through the TableFeatureAPI:
We require the table row height to be static and defined beforehand.
We can extend the TableFeatureAPI and add customRowRender option that allows us to control the row rendering. The renderer can then render all rows initially empty with the defined height. Then an intersection observer can be used to figure out which rows should actually render their cells.
The TableRenderLayer expects a component-ish to be provided for every cell content. There we can easily call a user-provided Component with a predefined API.
With the TableFeatureAPI we can reorder pinned rows and columns and mark the pinned entries. We add a reducers that adds a class to all pinned rows/column cells.
Should not happen if we use the vue key attribute correctly.
See 7.
The user needs to bind the v-models correctly and consider our emits, than that should not make any issues. State is owned by the TableLogicLayer. But we could consider an extension similar to our useManagedState approach, where we optional pass control over to the user.
Eventlisteners can be added without issue. The headless logic should rely on matching the semantic html and attributes to e.g. figure out which is the next cell to focus.
All events are allowed to bubble up, no shadow dom, no cry.
š§ Request For Comments! š§
The following proposal is up for discussion and will change. The proposed APIs are not complete. Please add your input as comments.
Why?
The aim is to have an extendable data grid with separated concerns and avoid too much intertwined code. So we define in this ticket the basic internal architecture for the future OnyxDataGrid.
Description
We begin by defining what table state is: Everything in our data grid that can change over time. So what can change?
:tableData="tableData"
)What is not considered state?
- ā ongoing user interactions, e.g. [composition](https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent) or when the user is dragging an element, but hasn't dropped it yet=> state is the sum of these data
or
or
Implementation
Technically, we will achieve this using three layers:
RenderTable
) representing the table (our "State Function")1. TableApiLayer
TBD - not relevant for this PoC
2. TableLogicLayer
ā¹ļø For the following examples and definitions, we assume a table is rendered out of the following, very simple API:
In this layer, we take the provided user table data and transform and extend it. Every feature acts as a transform with the following signature:
function enrichTable(userTableData: SimpleTableAPI): SimpleTableAPI
.Ideally, we implement our features with the same API that we provide for others ("Eat your own dog food").
We can optimize the transformation process by taking a look at the aspects of a table feature:
adding/changing/removing row data
We can imagine features which mutate row data as a sequence of array mutations: E.g. :
This can be abstracted into the following steps:
before
before
after
after
By grouping all mappings together, we can optimize the performance.
Adding/changing/removing column definitions
There is no need for optimizations here, as we can assume the rendered columns count to be a low. Therefore, we can just provide a simple mapping function.
Change how cells/headers/rows are rendered
For rendering, we make use of Vue's FunctionalComponents. These are always stateless, which is what we want for performance and philosophy reasons. The component must always take a Header component as a prop and wrap/use it.
E.g. a header could be extended like this:
E.g. a row would work like this
The same approach can be used for rows and cells.
Based on the above, I propose the following API:
General table data pipeline:
before
splice-additions
splice-virtuals
mapping
before
deleted
(e.g. user clicked delete)filtered
(e.g. built-in filter func)user-filtered
(custom user filter func(entry: Entry) => boolean
, only called if not already markeddeleted
orfiltered
)after
filter
sort
after
RenderRow
SimpleTableAPI
state and passes it to the TableRenderLayer3. TableRenderLayer
A minimal API that renders an HTML compliant table with only permitted content elements: