Page-Panel-Controller 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.
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:
| Option | Type | Default | Description |
|---|---|---|---|
content | class | required | Panel class reference |
controller | class | required | PanelController class reference |
title | string | required | Tab label text |
icon | string | — | FontAwesome icon class |
isDefault | boolean | false | Active tab on page load |
lazyLoad | boolean | true | Defer DOM creation until tab is selected |
autoReload | boolean | false | Re-fetch data on every tab click |
reloadOnRestart | boolean | false | Re-fetch data on WebSocket reconnect |
scope | MenuItemScope | — | 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 withnew T()in the constructor - Plain layout containers that need to be toggled or disabled are
GuiCommonsinstances, not rawHTMLDivElementreferences - The
data-oidis-bindattribute connects HTML elements to component references - Use
declarefor decorator-initialized properties:public declare Name : string - No event handlers, no data fetching, no conditionals — that's the controller's job
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: trueis 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
BindDatapattern) - 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(...)orcomponent.getEvents().setEvent(EventType.ON_KEY_UP, ...) - Dialog bindings via
new DialogBinding().Bind({...}) - Form validation logic
- CRUD state management (list/edit toggles)
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— settruewhen the resolve starts, back tofalseafter the load event completes (even on error). It means "a resolve is currently in flight".parallelProcessing— whether the controller allows overlappingProcess()calls. Defaults tofalse.
The guard runs the call when parallelProcessing is set or the controller is not already processing:
parallelProcessing | Already processing? | Result |
|---|---|---|
false (default) | no | runs normally |
false (default) | yes | skipped — logs Skipped parallel processing of <ClassName> |
true | no | runs normally |
true | yes | runs 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
}
}
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:
- Create the Panel — Define HTML structure and component references
- Create the PanelController — Implement lifecycle methods (FetchData, beforeLoad, resolver)
- Register in a Page — Add to
getTabsConfiguration()in the appropriate Page class - Add the Resolver — Decorate the PageController with
@Resolver("/your-path")(auto-detected) - 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