Tables
Table<T> is the primary data display component in Oidis. It supports server-side pagination, typed row components, and a consistent 3-callback binding pattern.
Package: io-oidis-usercontrols
import { ITableColumn, Table, TableItem } from "@io-oidis-usercontrols/Io/Oidis/UserControls/Bootstrap/UserControls/Table.js";
| Need | Use |
|---|---|
| Standard data grid with pagination | Table<T> (this page) |
| Runtime column toggling, mobile-responsive layout, select-all, row state | ResponsiveTable<T> |
| Simple static list (no pagination) | Table<T> with a fetch callback returning all records |
Start with Table<T> — it covers most cases. Switch to ResponsiveTable when you need column visibility controls or a mobile-optimized layout.
Table and Row Classes
A table is always a subclass of Table<T> that defines the header columns and creates its row instances. Rows extend
TableItem:
export class ProductsTable extends Table<ProductsTableItem> {
constructor() {
super();
this.localization = {
noData: "No products yet"
};
this.ItemsPerPage(25);
}
protected getHeaderConfig() : ITableColumn[] {
return [
{text: "Product"},
{text: "Price", modifiers: ["text-end"]},
{text: "Status"},
{text: "", width: "130px"}
];
}
protected getNewItemInstance() : ProductsTableItem {
return new ProductsTableItem();
}
}
export class ProductsTableItem extends TableItem {
public readonly productName : Label;
public readonly price : Label;
public readonly status : Label;
public readonly editButton : Button;
public readonly deleteButton : Button;
constructor() {
super();
this.productName = new Label();
this.price = new Label();
this.status = new Label();
this.editButton = new Button();
this.deleteButton = new Button();
}
protected innerHtml() : string {
return `
<tr class="align-middle" data-oidis-bind="${this}">
<td data-oidis-bind="${this.productName}"></td>
<td class="text-end" data-oidis-bind="${this.price}"></td>
<td data-oidis-bind="${this.status}"></td>
<td class="text-end text-nowrap">
<button class="btn btn-outline-secondary btn-sm" type="button" data-oidis-bind="${this.editButton}">Edit</button>
<button class="btn btn-outline-danger btn-sm" type="button" data-oidis-bind="${this.deleteButton}">Delete</button>
</td>
</tr>`;
}
}
| Member | Purpose |
|---|---|
getHeaderConfig() : ITableColumn[] | Header columns: text, optional width, enabled, modifiers (CSS classes) |
getNewItemInstance() : T | Creates a row — without the override the table creates plain TableItem rows |
localization.noData | Text of the empty-state alert (default "No data") |
setNoDataStyle($style) | Bootstrap alert variant of the empty state (default "danger") |
ItemsPerPage($value?) / PagesLimit($value?) | Page size and number of page buttons |
The row's innerHtml() binds ${this} to the <tr> element. Members are declared public readonly and created in the
constructor — see Panels and Controllers.
TableItem already declares the public text label, and GuiCommons declares private members such as data, events,
items, content, header, body or id. Declaring a member with one of those names fails to compile or breaks the
component — use specific names such as productName.
The 3-Callback BindData Pattern
Table data binding always happens in beforeLoad() using up to three callbacks:
public async beforeLoad($instance : ProductPanel) : Promise<void> {
$instance.table.BindData(
// Callback 1: FETCH — get one page of data from the backend
async ($offset : number, $limit : number) : Promise<IModelListResult> => {
return this.client.ListProducts($offset, $limit);
},
// Callback 2: MAP — populate a row with one record
async ($data : IProduct, $item : ProductsTableItem) : Promise<void> => {
$item.Data($data);
$item.productName.Text(escapeHtml($data.Name));
$item.price.Text(`${$data.Price} EUR`);
$item.status.Text($data.IsActive ? "Active" : "Inactive");
},
// Callback 3: CREATE — bind row-level interactions once per row instance
async ($item : ProductsTableItem) : Promise<void> => {
EventsBinding.SingleClick($item.editButton, async () : Promise<void> => {
this.currentRecord = $item.Data();
this.switchToEditMode($instance);
});
EventsBinding.SingleClick($item.deleteButton, async () : Promise<void> => {
this.recordToDelete = $item.Data();
$instance.deleteDialog.Visible(true);
});
}
);
}
Callback Details
Callback 1: Fetch ($offset, $limit) => Promise<IModelListResult>
- Called every time
ShowData()is invoked and when the user selects a page $offsetis the record offset (not a page number),$limitisItemsPerPage()- Must return
{data : any[], size : number, offset : number, limit : number}—sizeis the total number of matching records and drives the pagination controls - When the callback throws, the table logs a warning and shows the empty state; catch the error yourself when the user should see a message
Callback 2: Map ($data, $item, $index) => Promise<void>
- Called once per record of the current page
$data— the record fromdata$item— the typed row component- Store the record on the row with
$item.Data($data)for later retrieval - Populate text, format dates, set conditional styles
Callback 3: Create ($item, $index) => Promise<void> (optional)
- Called once when a row instance is created, not after every mapping
- Row instances are reused for every later page and refresh, so bind event handlers here and read the current record with
$item.Data()inside the handler
Label.Text() (and every Content()) writes innerHTML. Escape user-provided values before putting them into a row,
otherwise a record name such as <img src=x onerror=...> runs as script for every user viewing the table. See
GUI Pitfalls.
The backend counterpart returns the same shape, for example:
@Extern()
public async ListProducts($offset : number, $limit : number) : Promise<IModelListResult<IProduct>> {
const filter : any = {deleted: {$ne: true}};
const size : number = await Product.Model().countDocuments(filter);
const records : Product[] = await Product.Find(filter, {limit: $limit, offset: $offset, sort: {name: 1}});
return {
data : records.map(($record : Product) : IProduct => ({Id: $record.Id, IsActive: $record.IsActive, Name: $record.Name,
Price: $record.Price})),
limit : $limit,
offset: $offset,
size
};
}
Restore Pattern
The table reuses row instances when refreshing data. Before re-populating, Table.Restore() hides every row and calls its
Restore(). Override this method to reset all labels and buttons:
public Restore() : void {
super.Restore();
this.productName.Text(" ");
this.price.Text(" ");
this.status.Text(" ");
}
Rules:
- Always call
super.Restore()first - Reset
Labelfields with.Text(" ")(single space — not empty string) Buttonhas noText()— set its label with.Content(...)- Every field declared in the table item should be reset
Without Restore(), stale data from previously bound rows may leak into recycled rows. Rows beyond the size of the
current page stay hidden.
Label.Text($value) ignores an empty string (Property.String keeps the previous value) and re-renders the previous text. The same leak therefore happens in the data formatter: $item.note.Text($data.Note ?? "") on a row whose note is empty keeps the note of whichever record the recycled row showed before — after a sort or page change the cells look swapped between rows. Map empty values to " ", or write the cell through Content() (which does accept "").
Table Declaration in Panel
export class ProductPanel extends BasePanel {
public readonly table : ProductsTable;
constructor() {
super();
this.table = new ProductsTable();
}
protected innerHtml() : string {
return `
<div data-oidis-bind="${this.table}"></div>`;
}
}
Refreshing Data
// Reset to the first page and fetch
await $instance.table.ShowData(true);
// Fetch the current page again
await $instance.table.ShowData();
ShowData() always calls the fetch callback — there is no client-side cache. The argument only controls whether the
pagination offset is reset.
Other helpers: Clear() removes all rows and shows the empty state, IsEmpty() / Size() report the number of row
instances, getItem($index) returns a row.
When to Call ShowData
- After save/delete —
await $instance.table.ShowData()to reflect changes on the current page - In FetchData() — Initial data load
- After filter change —
await $instance.table.ShowData(true)to start from the first page
EventsBinding.SingleClick($instance.saveButton, async () : Promise<void> => {
try {
await this.client.SaveProduct(this.currentRecord);
this.ShowMessage("Saved", ToastType.SUCCESS);
this.switchToListMode($instance);
await $instance.table.ShowData();
} catch (ex) {
LogIt.Error("Save failed", ex);
this.ShowMessage("Failed to save", ToastType.ERROR);
}
});
Pagination
Pagination is handled by the Table<T> component:
- The fetch callback receives
$offsetand$limitfrom the table's pagination state - Your backend returns
{data: [...], size: N, offset, limit} - The table shows pagination controls when
sizeis greater thanItemsPerPage() - When the user selects a page,
BindDatare-runsShowData()with the new$offset
You don't manage pagination state manually — the table handles it.
Data Storage on Rows
Use .Data() to store and retrieve the full data object on each row:
// Map callback — store data on the row
async ($data : IProduct, $item : ProductsTableItem) : Promise<void> => {
$item.Data($data);
$item.productName.Text(escapeHtml($data.Name));
}
// Create callback — read the current record when the event fires
async ($item : ProductsTableItem) : Promise<void> => {
EventsBinding.SingleClick($item.editButton, async () : Promise<void> => {
this.currentRecord = $item.Data();
this.switchToEditMode(this.getInstance());
});
}
Because row instances are reused, never capture $data in a handler — it would keep pointing to the record the row
showed when the handler was bound.
Conditional Row Styling
Apply CSS classes or visibility based on data in the map callback. Reset them in Restore() — the next record mapped into
the row inherits them otherwise:
async ($data : IProduct, $item : ProductsTableItem) : Promise<void> => {
$item.Data($data);
$item.productName.Text(escapeHtml($data.Name));
$item.InstanceOwner().classList.toggle("text-muted", $data.Status === RecordStatus.INACTIVE);
$item.deleteButton.Visible($data.CanDelete);
}
See also: Panels and Controllers | Events and Validation | Dialogs