Skip to main content

Page-Panel-Controller Pattern

Bootstrap 5 System — Modern Pattern

This page documents the Bootstrap 5 (modern) Page-Panel-Controller pattern — the correct approach for all new Oidis development. If you encounter BasePanelViewer or large monolithic BasePanel classes in older code, those belong to the legacy system which is deprecated. See Bootstrap 5 vs Legacy for details.

tip

This page covers the architectural concepts behind Page-Panel-Controller. For practical coding patterns (GuiCommons API, event binding, CRUD toggles, dropdown population), see Panels and Controllers — Practical Guide. The data-oidis-bind attribute used in panel templates is explained in Data Binding.

Oidis uses a variant of the MVC (Model-View-Controller) pattern called Page-Panel-Controller. Every UI feature in an Oidis application is built from these three components working together.

The Three Layers​

Page — The Layout Container​

A Page extends BasePageLayout and defines which panels appear as tabs. It's the outermost container that users navigate to via URL.

export class MainPage extends BasePageLayout {

public getTabsConfiguration() : ITabsConfiguration[] {
return [
{
content: DashboardPanel,
controller: DashboardPanelController,
title: "Dashboard",
icon: "fa-home",
isDefault: true
},
{
content: UsersPanel,
controller: UsersPanelController,
title: "Users",
icon: "fa-users",
scope: MenuItemScope.ADMINS
},
{
content: SettingsPanel,
controller: SettingsPanelController,
title: "Settings",
lazyLoad: true
}
];
}
}

Tab configuration options:

OptionTypeDefaultDescription
contentclassrequiredPanel class reference
controllerclassrequiredPanelController class reference
titlestringrequiredTab label text
iconstring—FontAwesome icon class
isDefaultbooleanfalseActive tab on page load
lazyLoadbooleantrueDefer DOM creation until tab is selected
autoReloadbooleanfalseRe-fetch data on every tab click
reloadOnRestartbooleanfalseRe-fetch data on WebSocket reconnect
scopeMenuItemScope—Visibility restriction (ADMINS, HIDDEN, MOBILE_ONLY)

Panel — The Dumb View​

A Panel extends BasePanel and defines the HTML structure with component references. Panels are intentionally "dumb" — they contain no business logic, only markup and typed references to UI elements.

export class UsersPanel extends BasePanel {

public readonly table : Table<UserTableItem>;
public readonly searchField : TextField;
public readonly addButton : Button;
public readonly editContent : GuiCommons;
public readonly listContent : GuiCommons;
public readonly nameInput : TextField;
public readonly emailInput : TextField;
public readonly saveButton : Button;
public readonly cancelButton : Button;

constructor() {
super();
this.table = new Table<UserTableItem>();
this.searchField = new TextField();
this.addButton = new Button();
this.editContent = new GuiCommons();
this.listContent = new GuiCommons();
this.nameInput = new TextField();
this.emailInput = new TextField();
this.saveButton = new Button();
this.cancelButton = new Button();
}

public innerHtml() : string {
return `
<div data-oidis-bind="${this.listContent}" class="ListContent">
<div class="d-flex justify-content-between mb-3">
<div data-oidis-bind="${this.searchField}"></div>
<div data-oidis-bind="${this.addButton}"></div>
</div>
<div data-oidis-bind="${this.table}"></div>
</div>
<div data-oidis-bind="${this.editContent}" class="EditContent d-none">
<div data-oidis-bind="${this.nameInput}"></div>
<div data-oidis-bind="${this.emailInput}"></div>
<div class="d-flex gap-2">
<div data-oidis-bind="${this.saveButton}"></div>
<div data-oidis-bind="${this.cancelButton}"></div>
</div>
</div>
`;
}
}

Key rules for Panels:

  • All component properties are declared public readonly x : T; and created with new T() in the constructor
  • Plain layout containers that need to be toggled or disabled are GuiCommons instances, not raw HTMLDivElement references
  • The data-oidis-bind attribute connects HTML elements to component references
  • Use declare for decorator-initialized properties: public declare Name : string
  • No event handlers, no data fetching, no conditionals — that's the controller's job
Members are not instantiated by the framework

There is no decorator, reflection or build-time transform that creates panel members. A member declared as public readonly x : T = undefined; without a constructor assignment stays undefined — ${this.x} renders the text undefined and GuiCommons.Register() logs <Class>.<member> was declared but instance is missing. Did you miss it in constructor?. Declare the member as public readonly x : T; and assign this.x = new T(); in the constructor after super().

Controller — The Smart Brain​

A PanelController extends BasePanelController<TPanel> and contains all the logic: event handling, data fetching, validation, and UI state management.

export class UsersPanelController extends BasePanelController<UsersPanel> {

private readonly client : UsersConnector = UsersConnector.getClient();
private currentRecord : User = null;

public async FetchData() : Promise<void> {
try {
const data = await this.client.ListUsers();
await this.getInstance().table.ShowData(true);
} catch (ex) {
LogIt.Error("Failed to fetch users", ex);
}
}

public async beforeLoad($instance : UsersPanel) : Promise<void> {
$instance.table.BindData(
async ($offset, $limit) => {
return await this.client.ListUsers({ offset: $offset, limit: $limit });
},
async ($data, $item) => {
$item.Data($data);
$item.name.Text($data.Name);
$item.email.Text($data.Email);
},
async ($item) => {
EventsBinding.SingleClick($item.editBtn, async () => {
this.currentRecord = $item.Data();
this.showEditForm($instance);
});
}
);
}

public async resolver($instance : UsersPanel) : Promise<void> {
EventsBinding.SingleClick($instance.addButton, async () => {
this.currentRecord = null;
this.showEditForm($instance);
});

EventsBinding.SingleClick($instance.saveButton, async () => {
try {
if (!this.validateForm($instance)) return;
const record = ObjectValidator.IsEmptyOrNull(this.currentRecord)
? new User()
: this.currentRecord;
record.Name = $instance.nameInput.Value();
record.Email = $instance.emailInput.Value();
await this.client.SaveUser(record);
this.ShowMessage("User saved", ToastType.SUCCESS);
this.showListView($instance);
await this.FetchData();
} catch (ex) {
LogIt.Error("Failed to save user", ex);
this.ShowMessage("Failed to save user", ToastType.ERROR);
}
});

EventsBinding.SingleClick($instance.cancelButton, async () => {
this.showListView($instance);
});
}

private showEditForm($instance : UsersPanel) : void {
$instance.listContent.Visible(false);
$instance.editContent.Visible(true);
if (!ObjectValidator.IsEmptyOrNull(this.currentRecord)) {
$instance.nameInput.Value(this.currentRecord.Name);
$instance.emailInput.Value(this.currentRecord.Email);
}
}

private showListView($instance : UsersPanel) : void {
$instance.editContent.Visible(false);
$instance.listContent.Visible(true);
this.currentRecord = null;
}

// Validation logic — see Events and Validation guide for the full pattern:
// ../07-GUI/03-EventsAndValidation.mdx
private validateForm($instance : UsersPanel) : boolean {
// ... restore marks, check fields, return boolean
}
}

Controller Lifecycle​

The controller lifecycle is managed by AsyncBaseHttpController and follows a strict order:

constructor($instance)
│
▼
FetchData() ← Load data from backend
│
▼
beforeLoad($instance) ← Bind table data, set input restrictions
│
▼
resolver($instance) ← Bind events, setup dialogs, form logic

constructor​

Called once when the controller is instantiated. Use it for:

  • Getting connector singletons: this.client = MyConnector.getClient()
  • Initializing state variables: this.currentRecord = null

FetchData()​

Called to load data from the backend. This method is invoked:

  • On initial page load (for visible/default tabs)
  • When a tab with autoReload: true is clicked
  • When a WebSocket connection is restored (if reloadOnRestart: true)
  • Manually via await this.FetchData() after save/delete operations

beforeLoad($instance)​

Called once, before the panel is fully interactive. Use it for:

  • Table data binding (the 3-callback BindData pattern)
  • Setting input restrictions (ValidationBinding.setOnlyNumbersAllowed(), etc.)
  • One-time UI setup that depends on the panel being in the DOM

For lazy-loaded tabs, beforeLoad is called when the tab is first selected — not on page load.

resolver($instance)​

Called once, after beforeLoad. This is where most of the logic lives:

  • Click handlers via EventsBinding.SingleClick(); other events via the component's event manager, e.g. component.getEvents().setOnChange(...) or component.getEvents().setEvent(EventType.ON_KEY_UP, ...)
  • Dialog bindings via new DialogBinding().Bind({...})
  • Form validation logic
  • CRUD state management (list/edit toggles)
Smart Controller, Dumb Panel

The controller is the "brain" of the feature. The panel is a passive template. If you find yourself adding logic to a Panel class, move it to the Controller instead.

Process() and the duplicate-call guard​

AsyncBaseHttpController.Process() is the entry point that drives the lifecycle above (FetchData → beforeLoad → resolver). It is re-entrancy guarded: while a controller's async resolve is still in flight, a second Process() on the same controller instance is dropped instead of running concurrently.

Two internal states drive this:

  • processing — set true when the resolve starts, back to false after the load event completes (even on error). It means "a resolve is currently in flight".
  • parallelProcessing — whether the controller allows overlapping Process() calls. Defaults to false.

The guard runs the call when parallelProcessing is set or the controller is not already processing:

parallelProcessingAlready processing?Result
false (default)noruns normally
false (default)yesskipped — logs Skipped parallel processing of <ClassName>
truenoruns normally
trueyesruns anyway — logs Executed parallel processing of <ClassName>

Why it exists: without the guard, a rapid double-trigger (a page re-entered before its previous resolve finished, or a duplicate route dispatch) would run the whole FetchData / beforeLoad / resolver chain twice at once on one controller — duplicating event bindings, data loads and DOM work. The guard makes the safe behavior the default: the second call is dropped.

Opting into parallel processing​

If a controller is intentionally designed to handle overlapping calls, opt in with the ParallelProcessing() accessor (Oidis property style — no argument reads, a boolean sets):

export class MyPageController extends AsyncBasePageController {
constructor() {
super();
this.ParallelProcessing(true); // allow overlapping Process() calls
}
}
Guarded by default for every controller

The guard lives in the shared AsyncBaseHttpController, so all controllers are guarded by default — safe for the vast majority of cases. Only set ParallelProcessing(true) where concurrent resolves are genuinely intended; the Executed parallel processing of … warning exists to flag that this is the unusual path.

Tab Behavior​

Lazy Loading (Default)​

By default, tabs are lazy-loaded (lazyLoad: true). The panel's DOM is not created until the user clicks the tab for the first time. This improves initial page load performance.

For non-lazy tabs (lazyLoad: false), the DOM is created immediately on page load, and beforeLoad is called right away.

Auto-Reload​

When autoReload: true, the controller's FetchData() is called every time the user clicks the tab — not just the first time. Use this for data that changes frequently.

Reload on Restart​

When reloadOnRestart: true, the controller's FetchData() is called when the WebSocket connection is restored after a disconnect. Useful for real-time dashboards.

Access Control​

Page-Level Access​

Override accessValidator() in your PageController to restrict access to the entire page:

protected async accessValidator() : Promise<boolean> {
const auth = AuthManagerConnector.getClient();
return await auth.IsAuthorized();
}

If accessValidator() returns false, the user is redirected via reloadToNotAuthorized().

Tab-Level Access​

Override AccessValidator() (capital A) in your PanelController to restrict individual tabs:

public async AccessValidator() : Promise<boolean> {
const auth = AuthManagerConnector.getClient();
return await auth.HasPermission("admin.users");
}

Combined with the scope option in tab configuration, you have fine-grained control over who sees what.

Sub-Panel Pattern​

For complex UIs, a Panel can contain other Panels as sub-components:

export class OrdersPanel extends BasePanel {
public readonly detailsSubPanel : OrderDetailsPanel;
public readonly table : Table<OrderTableItem>;

constructor() {
super();
this.detailsSubPanel = new OrderDetailsPanel();
this.table = new Table<OrderTableItem>();
}

public innerHtml() : string {
return `
<div data-oidis-bind="${this.table}"></div>
<div data-oidis-bind="${this.detailsSubPanel}"></div>
`;
}
}

Important: Sub-panels do not have their own controllers. The parent controller manages sub-panel logic directly:

// In the parent PanelController
public async resolver($instance : OrdersPanel) : Promise<void> {
EventsBinding.SingleClick($instance.table.selectedRow, async () => {
const order = $instance.table.selectedRow.Data();
$instance.detailsSubPanel.orderName.Text(order.Name);
$instance.detailsSubPanel.Visible(true);
});
}

Putting It All Together​

Here's the typical workflow for creating a new feature:

  1. Create the Panel — Define HTML structure and component references
  2. Create the PanelController — Implement lifecycle methods (FetchData, beforeLoad, resolver)
  3. Register in a Page — Add to getTabsConfiguration() in the appropriate Page class
  4. Add the Resolver — Decorate the PageController with @Resolver("/your-path") (auto-detected)
  5. Connect to Backend — Add methods to an existing connector or create a new one

The minimum viable feature requires just two files: a Panel and a PanelController.


See also: Panels and Controllers | Data Binding | Architecture Overview