Skip to main content

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";
Table<T> vs ResponsiveTable — which one?
NeedUse
Standard data grid with paginationTable<T> (this page)
Runtime column toggling, mobile-responsive layout, select-all, row stateResponsiveTable<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>`;
}
}
MemberPurpose
getHeaderConfig() : ITableColumn[]Header columns: text, optional width, enabled, modifiers (CSS classes)
getNewItemInstance() : TCreates a row — without the override the table creates plain TableItem rows
localization.noDataText 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.

Member names in rows

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
  • $offset is the record offset (not a page number), $limit is ItemsPerPage()
  • Must return {data : any[], size : number, offset : number, limit : number} — size is 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 from data
  • $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 renders HTML

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 Label fields with .Text(" ") (single space — not empty string)
  • Button has no Text() — 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.

Empty values in the data formatter

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:

  1. The fetch callback receives $offset and $limit from the table's pagination state
  2. Your backend returns {data: [...], size: N, offset, limit}
  3. The table shows pagination controls when size is greater than ItemsPerPage()
  4. When the user selects a page, BindData re-runs ShowData() 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