Skip to main content

Panels and Controllers

Bootstrap 5 System — Modern Pattern

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 readonly for all UI components — Prevents accidental reassignment
  • Create every component in the constructor — declare the member without an initializer and assign new Xyz() after super(). Nothing creates the instances automatically; a member left undefined breaks data-oidis-bind at render time
  • Unique member names — never reuse a name of a private or protected member of GuiCommons or 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
Never create UI with raw HTML

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):

MethodPurposeExample
.Value($v?)Get/set input value (TextField, CheckBox, …)input.Value() / input.Value("hello")
.Text($v?)Get/set display text (Label, dropdown items) — rendered as HTMLlabel.Text("Status: OK")
.Content($v)Set inner HTML (setter only, returns void)button.Content("Save")
.Data($v?)Get/set associated data objectrow.Data(record) / row.Data()
.Visible($v?)Show/hide componentpanel.Visible(false)
.Enabled($v?)Enable/disable componentbutton.Enabled(false)
.Active($v?)Get/set active statetab.Active(true)
.InstanceOwner()Get underlying HTMLElementinput.InstanceOwner().scrollIntoView()
.getEvents()Get ElementEventsManagerbutton.getEvents().setOnClick(...)
.Clear()Remove all childrendropdown.Clear()
.AddChild($el)Append child componentdropdown.AddChild(item)
When to use which
  • .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 — Button has no Text()
  • .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:

#MethodTriggered byPurpose
1constructorTab registration in afterLoad()Initialize connector refs, state variables
2resolver()Process() during page setupBind event handlers (buttons, dialogs)
3beforeLoad()selectTab() on first tab displayOne-time setup: table bindings, input restrictions
4FetchData()onTabLoad() → processTabContent()Load data + update UI
FetchData runs LAST

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.

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() 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});
Always await NavigateTo

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:

SituationResult
Different URLNavigates via window.location.hash, waits for resolver
Same URL, force: trueRe-fires the HTTP request event (re-triggers FetchData)
Same URL, no forceLogs an error — no navigation happens
External/absolute URLThrows 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");
When to use which
  • 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​

WhatWhereLifecycle Method
Connector singletonconstructor—
State variablesconstructor—
Panel UI componentsPanel constructor—
Table BindData()beforeLoad()Once
Input restrictionsbeforeLoad()Once
Dropdown populationbeforeLoad() or FetchData()Once or on refresh
Event handlersresolver()Once
Dialog bindingsresolver()Once
Form logicresolver()Once
Data loadingFetchData()On load + refresh
ValidationPrivate method, called from eventsOn demand

See also: Page-Panel-Controller | Data Binding | Tables | Events and Validation