Panels and Controllers
All patterns in this guide use the Bootstrap 5 (modern) system — BasePanelController<Panel>, BasePanel with innerHtml(), and data-oidis-bind. This is the correct approach for all new Oidis development.
If you encounter BasePanelViewer, BaseViewer, or a very large BasePanel class (thousands of lines) in older code, those are part of the legacy system which is deprecated and must not be used for new features. See Bootstrap 5 vs Legacy.
This guide covers the practical patterns you'll use daily when building Oidis UI features. It builds on the Page-Panel-Controller architecture overview with concrete coding patterns.
The Panel — Your HTML Template
A Panel defines the view. It extends BasePanel and returns HTML from innerHtml(), with readonly properties bound to UI components via data-oidis-bind.
export class SettingsPanel extends BasePanel {
public readonly nameInput : TextField;
public readonly emailInput : TextField;
public readonly phoneInput : TextField;
public readonly roleDropdown : DropDownList;
public readonly saveButton : Button;
public readonly statusLabel : Label;
public readonly confirmDialog : Dialog;
public readonly confirmYes : Button;
public readonly confirmNo : Button;
constructor() {
super();
this.nameInput = new TextField();
this.emailInput = new TextField();
this.phoneInput = new TextField();
this.roleDropdown = new DropDownList();
this.saveButton = new Button();
this.statusLabel = new Label();
this.confirmDialog = new Dialog();
this.confirmYes = new Button();
this.confirmNo = new Button();
}
protected innerHtml() : string {
return `
<div class="SettingsForm">
<div class="row mb-3">
<div class="col-md-6">
<input class="form-control" type="text" data-oidis-bind="${this.nameInput}">
<div class="invalid-feedback"></div>
</div>
<div class="col-md-6">
<input class="form-control" type="email" data-oidis-bind="${this.emailInput}">
<div class="invalid-feedback"></div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6"><input class="form-control" type="tel" data-oidis-bind="${this.phoneInput}"></div>
<div class="col-md-6" data-oidis-bind="${this.roleDropdown}"></div>
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" type="button" data-oidis-bind="${this.saveButton}">Save</button>
<span data-oidis-bind="${this.statusLabel}"></span>
</div>
</div>
<div class="modal fade" role="dialog" tabindex="-1" data-oidis-bind="${this.confirmDialog}">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body"><p>Are you sure you want to save these changes?</p></div>
<div class="modal-footer">
<button class="btn btn-light" type="button" data-oidis-bind="${this.confirmNo}">Cancel</button>
<button class="btn btn-primary" type="button" data-oidis-bind="${this.confirmYes}">Save</button>
</div>
</div>
</div>
</div>`;
}
}
Panel Rules
public readonlyfor all UI components — Prevents accidental reassignment- Create every component in the constructor — declare the member without an initializer and assign
new Xyz()aftersuper(). Nothing creates the instances automatically; a member leftundefinedbreaksdata-oidis-bindat render time - Unique member names — never reuse a name of a private or protected member of
GuiCommonsor the base panel (active,data,events,id,items,content,header,body,text, …) — see GUI Pitfalls - No logic in panels — No event handlers, no conditions, no imports of business classes
Do NOT use document.createElement(), innerHTML, or appendChild() to build interactive UI.
All UI elements must be created as framework component instances (new Button(), new Label(),
new DropDownListItem(), etc.) and added via .AddChild().
Raw DOM manipulation bypasses the framework's type safety, event lifecycle, and component management.
The only exception is static layout markup in innerHtml() (Bootstrap grid classes, wrapper divs).
See Vanilla vs Framework for examples and allowed exceptions.
GuiCommons Component API
Every UI control in Oidis shares a common API through GuiCommons. These are the methods you'll use most (the first two
rows are provided by the concrete controls, not by GuiCommons itself):
| Method | Purpose | Example |
|---|---|---|
.Value($v?) | Get/set input value (TextField, CheckBox, …) | input.Value() / input.Value("hello") |
.Text($v?) | Get/set display text (Label, dropdown items) — rendered as HTML | label.Text("Status: OK") |
.Content($v) | Set inner HTML (setter only, returns void) | button.Content("Save") |
.Data($v?) | Get/set associated data object | row.Data(record) / row.Data() |
.Visible($v?) | Show/hide component | panel.Visible(false) |
.Enabled($v?) | Enable/disable component | button.Enabled(false) |
.Active($v?) | Get/set active state | tab.Active(true) |
.InstanceOwner() | Get underlying HTMLElement | input.InstanceOwner().scrollIntoView() |
.getEvents() | Get ElementEventsManager | button.getEvents().setOnClick(...) |
.Clear() | Remove all children | dropdown.Clear() |
.AddChild($el) | Append child component | dropdown.AddChild(item) |
.Value()— For form inputs (TextField, CheckBox).Text()— For display elements (Label, table cells); escape user data, the value is written as HTML.Content()— For HTML content and for button labels —Buttonhas noText().Data()— For storing/retrieving a business object on any component
Controller Lifecycle — Practical Guide
Execution Order (Tab System)
When a panel controller lives inside a Page Layout tab system, the framework calls lifecycle methods in this order:
| # | Method | Triggered by | Purpose |
|---|---|---|---|
| 1 | constructor | Tab registration in afterLoad() | Initialize connector refs, state variables |
| 2 | resolver() | Process() during page setup | Bind event handlers (buttons, dialogs) |
| 3 | beforeLoad() | selectTab() on first tab display | One-time setup: table bindings, input restrictions |
| 4 | FetchData() | onTabLoad() → processTabContent() | Load data + update UI |
All data-dependent UI updates — setting labels, rendering charts, populating non-table UI — must go inside FetchData(), not in beforeLoad() or resolver(). Those methods run before data is available.
Tables are the exception: Register BindData() callback in beforeLoad(), then call ShowData() inside FetchData(). The callback executes lazily when ShowData() triggers it — at that point data is already loaded.
With autoReload: true in tab config, FetchData() is called again on every tab revisit, making it the right place for data refresh logic.
constructor — Initialize Dependencies
export class SettingsPanelController extends BasePanelController<SettingsPanel> {
private readonly client : SettingsConnector;
private currentSettings : AppSettings;
private roles : Role[];
constructor() {
super();
this.client = SettingsConnector.getClient();
this.currentSettings = null;
this.roles = [];
}
}
Keep the constructor minimal. Get singleton connector references, declare state variables.
FetchData() — Load from Backend
public async FetchData() : Promise<void> {
try {
this.currentSettings = await this.client.GetSettings();
this.roles = await this.client.ListRoles();
const $instance = this.getInstance();
$instance.nameInput.Value(this.currentSettings.Name);
$instance.emailInput.Value(this.currentSettings.Email);
$instance.phoneInput.Value(this.currentSettings.Phone);
} catch (ex) {
LogIt.Error("Failed to fetch settings", ex);
this.ShowMessage("Failed to load settings", ToastType.ERROR);
}
}
Always wrap in try/catch. Use LogIt.Error() for logging and this.ShowMessage() for user feedback.
beforeLoad() — Bind Tables and Restrictions
public async beforeLoad($instance : SettingsPanel) : Promise<void> {
// Input format restrictions
ValidationBinding.setOnlyPhoneFormat($instance.phoneInput);
// Populate dropdown
this.populateRolesDropdown($instance);
}
This runs once, before the panel is interactive. Perfect for one-time setup.
resolver() — Bind Events and Logic
public async resolver($instance : SettingsPanel) : Promise<void> {
// Dialog binding — a single close button also gets a click handler that hides the dialog
new DialogBinding().Bind({
close : $instance.confirmNo,
holder: $instance.confirmDialog,
submit: $instance.confirmYes
});
// Save button
EventsBinding.SingleClick($instance.saveButton, async () : Promise<void> => {
try {
if (!this.validateForm($instance)) {
return;
}
$instance.confirmDialog.Visible(true);
} catch (ex) {
LogIt.Error("Save failed", ex);
this.ShowMessage("An error occurred", ToastType.ERROR);
}
});
// Confirm dialog submit
EventsBinding.SingleClick($instance.confirmYes, async () : Promise<void> => {
try {
$instance.confirmDialog.Visible(false);
this.currentSettings.Name = $instance.nameInput.Value();
this.currentSettings.Email = $instance.emailInput.Value();
this.currentSettings.Phone = $instance.phoneInput.Value();
await this.client.SaveSettings(this.currentSettings);
this.ShowMessage("Settings saved", ToastType.SUCCESS);
} catch (ex) {
LogIt.Error("Save failed", ex);
this.ShowMessage("Failed to save settings", ToastType.ERROR);
}
});
}
Event Binding
Oidis uses EventsBinding.SingleClick() for button clicks and the component events manager (getEvents().setOnChange(),
setOnFocus(), setEvent("oninput", …), …) for all other UI events — never native addEventListener. EventsBinding has no
other methods. Every async event handler must include a try/catch block.
For the full API reference, available event types, and error handling patterns, see Events and Validation.
Validation Pattern
Form fields validate themselves: configure TextField.Required() and SetValidator(), call ResetValidation() and
Validate() before saving, and set the message with InvalidFeedback() (the layout needs an .invalid-feedback element).
ValidationBinding (io-oidis-usercontrols) adds IsValidInput(), RestoreValidMark(), RequiredFields() and the input
format restrictions (numbers-only, phone, ZIP), which are set up in beforeLoad().
For the complete validation API and code examples, see Events and Validation.
Table Binding — The 3-Callback Pattern
Tables use a three-callback BindData() pattern in beforeLoad(): one callback to fetch a page (IModelListResult
{data, size, offset, limit}), one to map a record to a row, and one called once per created row instance to bind its events.
Refresh with $instance.table.ShowData() (ShowData(true) also resets to the first page).
For full table binding examples, pagination, and row event patterns, see Tables.
Dialog Pattern
Dialogs are Dialog components bound to a Bootstrap .modal element. DialogBinding (io-oidis-usercontrols) adds
Esc/Enter handling and hides the dialog for a single close button. Bind in resolver(), open with .Visible(true), and
handle confirmation via EventsBinding.SingleClick().
For the complete dialog binding API and confirmation patterns, see Dialogs.
Dropdown Population Pattern
Use the self-contained DropDownList and DropDownMultiselect components — they generate their own toggle button and menu. See User Controls — DropDownList for full API.
// Single-select — DropDownList
private buildCategoryMenu($instance : MyPanel) : void {
$instance.categoryDropdown.Clear();
for (const category of this.categories) {
const item : DropDownListItem = $instance.categoryDropdown.AddItem(category.Name);
item.Data(category);
item.getEvents().setOnClick(() : void => {
this.selectedCategoryId = category.Id;
$instance.categoryDropdown.Text(category.Name);
});
}
if (this.categories.length > 0) {
this.selectedCategoryId = this.categories[0].Id;
$instance.categoryDropdown.Text(this.categories[0].Name);
}
}
// Multi-select — DropDownMultiselect
private buildTagMenu($instance : MyPanel) : void {
$instance.tagDropdown.Clear();
for (const tag of this.tags) {
const item : DropDownSelectItem = $instance.tagDropdown.AddItem(tag.Label);
item.Data(tag);
}
}
// Read selected items (in event handler)
const selectedTags : Tag[] = <Tag[]>$instance.tagDropdown.Data();
CRUD UI Toggle Pattern
Most Oidis panels use a list/edit toggle for CRUD operations:
// Switch to edit mode
private switchToEditMode($instance : MyPanel) : void {
$instance.listContent.Visible(false);
$instance.editContent.Visible(true);
if (!ObjectValidator.IsEmptyOrNull(this.currentRecord)) {
// Editing existing record — populate form
$instance.nameInput.Value(this.currentRecord.Name);
$instance.emailInput.Value(this.currentRecord.Email);
} else {
// Creating new record — clear form
$instance.nameInput.Value("");
$instance.emailInput.Value("");
}
}
// Switch back to list
private switchToListMode($instance : MyPanel) : void {
$instance.editContent.Visible(false);
$instance.listContent.Visible(true);
this.currentRecord = null;
}
Save Handler — New vs Edit
EventsBinding.SingleClick($instance.saveButton, async () : Promise<void> => {
try {
if (!this.validateForm($instance)) {
return;
}
const record : MyRecord = ObjectValidator.IsEmptyOrNull(this.currentRecord)
? new MyRecord()
: this.currentRecord;
record.Name = $instance.nameInput.Value();
record.Email = $instance.emailInput.Value();
await this.client.SaveRecord(record);
this.ShowMessage("Saved successfully", ToastType.SUCCESS);
this.switchToListMode($instance);
await this.FetchData();
} catch (ex) {
LogIt.Error("Save failed", ex);
this.ShowMessage("Failed to save", ToastType.ERROR);
}
});
Store Pattern — Shared State
When multiple controllers need to share state, use a Store — a static singleton class:
import { ObjectValidator } from "@io-oidis-commons/Io/Oidis/Commons/Utils/ObjectValidator.js";
export class PendingRegistrationStore {
private static data : IRegistrationData = null;
static set($data : IRegistrationData) : void {
this.data = $data;
}
static get() : IRegistrationData {
return this.data;
}
static Clear() : void {
this.data = null;
}
static HasPending() : boolean {
return !ObjectValidator.IsEmptyOrNull(this.data);
}
}
Naming: set() and get() are getter/setter methods → camelCase. Clear() and HasPending() are regular public methods → PascalCase. See Naming Conventions.
Usage in controllers:
// Controller A — sets the data
PendingRegistrationStore.set(registrationData);
// Controller B — reads the data
if (PendingRegistrationStore.HasPending()) {
const data : IRegistrationData = PendingRegistrationStore.get();
// ... use data
PendingRegistrationStore.Clear();
}
Frontend Connector Singleton
Connectors use a memoized singleton pattern:
export class MyConnector extends BaseConnector {
public static getClient() : MyConnector {
const instance : MyConnector = new MyConnector(true,
Loader.getInstance().getHttpManager().CreateLink("/connector.config.jsonp"));
// Replace the static method with a closure returning the same instance
MyConnector.getClient = () : MyConnector => instance;
return instance;
}
public async ListRecords($offset : number, $limit : number) : Promise<IModelListResult> {
return await this.asyncInvoke("ListRecords", $offset, $limit);
}
public async SaveRecord($data : IMyRecord) : Promise<string> {
return await this.asyncInvoke("SaveRecord", $data);
}
protected getClientType() : WebServiceClientType {
return WebServiceClientType.HUB_CONNECTOR;
}
protected getServerNamespaces() : any {
const namespaces : any = {};
namespaces[WebServiceClientType.HUB_CONNECTOR] = "Io.Oidis.YourProject.Connectors.MyConnector";
return namespaces;
}
}
Programmatic Navigation
Controllers navigate between pages using this.getHttpManager(). There are two methods — always prefer NavigateTo() for SPA navigation.
NavigateTo() — SPA Navigation (Preferred)
NavigateTo() is async and uses the hash-based history API to navigate without a full page reload. It waits for the target resolver to complete before resolving the promise.
// Always await NavigateTo — it returns Promise<void>
await this.getHttpManager().NavigateTo("/settings");
// With force option — re-triggers FetchData even if already on the same URL
await this.getHttpManager().NavigateTo("/main", {force: true});
NavigateTo() is async and must be awaited. Without await, the navigation fires but the calling code continues before the target page is ready — this can cause race conditions with stores, data loading, and UI state.
// ✅ Correct — waits for navigation to complete
await this.getHttpManager().NavigateTo("/settings");
// ❌ Wrong — fire-and-forget, potential race condition
this.getHttpManager().NavigateTo("/settings");
Common pattern — Store + Navigate:
When passing data between panels (e.g., clicking a record to navigate to a detail panel), use a static Store to pass data and then navigate:
EventsBinding.SingleClick($item.detailButton, async ($args : EventArgs) : Promise<void> => {
$args.PreventDefault();
PendingDetailStore.set(record.Id);
await this.getHttpManager().NavigateTo("/details");
});
Behavior:
| Situation | Result |
|---|---|
| Different URL | Navigates via window.location.hash, waits for resolver |
Same URL, force: true | Re-fires the HTTP request event (re-triggers FetchData) |
| Same URL, no force | Logs an error — no navigation happens |
| External/absolute URL | Throws an error — only relative SPA URLs are supported |
Timeout: NavigateTo has a default 5-second timeout. If the target resolver doesn't complete within this window, the promise rejects with a timeout error.
ReloadTo() — Full Page Reload
ReloadTo() is synchronous and changes window.location.hash or window.location.href directly. Use it only for special cases like error pages, form submissions, or when you need a clean browser state.
// Synchronous — changes location immediately
this.getHttpManager().ReloadTo("/ServerError/Http/Forbidden");
NavigateTo()— Normal SPA navigation between panels/pages. Preserves page state, no flicker.ReloadTo()— Error pages, external redirects, or when you need a full browser refresh.
Quick Reference — What Goes Where
| What | Where | Lifecycle Method |
|---|---|---|
| Connector singleton | constructor | — |
| State variables | constructor | — |
| Panel UI components | Panel constructor | — |
Table BindData() | beforeLoad() | Once |
| Input restrictions | beforeLoad() | Once |
| Dropdown population | beforeLoad() or FetchData() | Once or on refresh |
| Event handlers | resolver() | Once |
| Dialog bindings | resolver() | Once |
| Form logic | resolver() | Once |
| Data loading | FetchData() | On load + refresh |
| Validation | Private method, called from events | On demand |
See also: Page-Panel-Controller | Data Binding | Tables | Events and Validation