Common Patterns
This page provides end-to-end recipes for common features. Each pattern shows the backend, frontend, and wiring needed to build a complete feature. For deeper explanation of any component, follow the links to the reference pages.
CRUD Panel
The most common pattern — a panel that lists records in a table, with buttons to create, edit, and delete.
1. Backend model
import { BaseModel } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/BaseModel.js";
import { Entity, Property } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/Decorators.js";
@Entity("products")
export class ProductRecord extends BaseModel<ProductRecord> {
@Property({type: String})
public declare Name : string;
@Property({type: Number, required: false, default: 0})
public declare Price : number;
@Property({type: Boolean, required: false, default: true})
public declare IsActive : boolean;
}
required defaults to true — mark optional fields with required: false. The stored field name (symbol) defaults to the
camelCase property name (name, price, isActive); use those names in queries.
Reference: Models Overview | @Entity and @Property
2. Backend connector
import { BaseConnector } from "@io-oidis-hub/Io/Oidis/Hub/Primitives/BaseConnector.js";
import { Extern } from "@io-oidis-localhost/Io/Oidis/Localhost/Primitives/Decorators.js";
import { IModelListResult } from "@io-oidis-services/Io/Oidis/Services/Connectors/AuthManagerConnector.js";
export class ProductManager extends BaseConnector {
@Extern()
public async ListProducts($offset : number, $limit : number) : Promise<IModelListResult<IProductView>> {
const filter : any = {deleted: {$ne: true}};
const size : number = await ProductRecord.Model().countDocuments(filter);
const records : ProductRecord[] = await ProductRecord.Find(filter, {limit: $limit, offset: $offset, sort: {name: 1}});
return {
data : records.map(($record : ProductRecord) : IProductView => ({
id: $record.Id, isActive: $record.IsActive !== false, name: $record.Name, price: $record.Price
})),
limit : $limit,
offset: $offset,
size
};
}
@Extern()
public async SaveProduct($input : IProductView) : Promise<string> {
let record : ProductRecord = ObjectValidator.IsEmptyOrNull($input.id) ? null : await ProductRecord.FindById($input.id);
if (ObjectValidator.IsEmptyOrNull(record) || record.Deleted === true) {
record = new ProductRecord();
}
// Copy fields explicitly — a client payload must never be saved as a model
record.Name = $input.name;
record.Price = $input.price;
record.IsActive = $input.isActive;
await record.Save();
return record.Id;
}
@Extern()
public async DeleteProduct($id : string) : Promise<void> {
const record : ProductRecord = await ProductRecord.FindById($id);
if (!ObjectValidator.IsEmptyOrNull(record)) {
await record.Delete();
}
}
}
@Extern methods are plain async methods — the framework checks the method permission before they run. Checks that depend
on the data (ownership, organization) belong inside the method; see @Extern.
Reference: LCP & RPC | @Extern
3. Register in AuthManager
// In your AuthManager constructor
this.generateAuthMethodsFrom<ProductManager>(AuthMethodCategory.ONLY_FOR_AUTHORIZED_USERS,
ProductManager, ($proto : ProductManager) => [
$proto.ListProducts,
$proto.SaveProduct,
$proto.DeleteProduct
]);
Reference: Authentication | System Groups
4. Frontend connector
import { WebServiceClientType } from "@io-oidis-services/Io/Oidis/Services/Enums/WebServiceClientType.js";
import { BaseConnector } from "@io-oidis-services/Io/Oidis/Services/Primitives/BaseConnector.js";
export class ProductManagerConnector extends BaseConnector {
public static getClient() : ProductManagerConnector {
const instance : ProductManagerConnector = new ProductManagerConnector(true,
Loader.getInstance().getHttpManager().CreateLink("/connector.config.jsonp"));
ProductManagerConnector.getClient = () : ProductManagerConnector => instance;
return instance;
}
public async ListProducts($offset : number, $limit : number) : Promise<IModelListResult<IProductView>> {
return await this.asyncInvoke("ListProducts", $offset, $limit);
}
public async SaveProduct($input : IProductView) : Promise<string> {
return await this.asyncInvoke("SaveProduct", $input);
}
public async DeleteProduct($id : string) : Promise<void> {
return await this.asyncInvoke("DeleteProduct", $id);
}
protected getClientType() : WebServiceClientType {
return WebServiceClientType.HUB_CONNECTOR;
}
protected getServerNamespaces() : any {
const namespaces : any = {};
namespaces[WebServiceClientType.HUB_CONNECTOR] = "Io.Oidis.YourProject.Utils.ProductManager";
return namespaces;
}
}
Reference: Connectors Reference
5. Frontend panel + controller
// Panel
export class ProductsPanel extends BasePanel {
public readonly table : ProductsTable; // Table<ProductsTableItem> subclass, see Tables
public readonly addButton : Button;
public readonly dialog : ProductDialog; // Dialog subclass with the form, see Dialogs
constructor() {
super();
this.table = new ProductsTable();
this.addButton = new Button();
this.dialog = new ProductDialog();
}
}
// Controller
@Resolver("/products")
export class ProductsController extends BasePanelController<ProductsPanel> {
private readonly client : ProductManagerConnector;
constructor($instance : ProductsPanel) {
super($instance);
this.client = ProductManagerConnector.getClient();
}
public async FetchData() : Promise<void> {
await this.getInstanceOwner().table.ShowData(true);
}
protected async beforeLoad($instance : ProductsPanel) : Promise<void> {
await super.beforeLoad($instance);
EventsBinding.SingleClick($instance.addButton, async () : Promise<void> => {
$instance.dialog.Open(null);
});
$instance.table.BindData(
async ($offset : number, $limit : number) : Promise<IModelListResult> =>
await this.client.ListProducts($offset, $limit),
async ($data : IProductView, $item : ProductsTableItem) : Promise<void> => {
$item.Data($data);
$item.productName.Text(escapeHtml($data.name)); // Label.Text writes innerHTML — escape user data, see GUI Pitfalls
},
async ($item : ProductsTableItem) : Promise<void> => {
EventsBinding.SingleClick($item.edit, async () : Promise<void> => {
$instance.dialog.Open($item.Data());
});
});
}
}
Reference: Panels and Controllers | Tables | Data Binding | GUI Pitfalls
Table with Pagination
Table and ResponsiveTable page by themselves. The fetch callback of BindData receives $offset and $limit and must
return IModelListResult — {data, size, offset, limit} where size is the total count. The table renders the current page,
shows the pagination when size > ItemsPerPage() and calls the fetch again when the user selects another page.
$instance.table.ItemsPerPage(20);
$instance.table.BindData(
async ($offset : number, $limit : number) : Promise<IModelListResult> => await this.client.ListProducts($offset, $limit),
async ($data : IProductView, $item : ProductsTableItem) : Promise<void> => { /* map record to row */ });
await $instance.table.ShowData(true); // true = back to the first page, e.g. after a search change
Rows are pooled and reused between pages. Bind row events once in the third BindData callback and read the current record
with $item.Data() at click time. Call table.Clear() before ShowData(true) when the number of rows can grow.
See Tables and ResponsiveTable.
Dialog for Create / Edit
A Dialog subclass owns the form; the controller opens it pre-filled for editing, or empty for creating, and binds the save:
// Dialog
export class ProductDialog extends Dialog {
public readonly nameField : TextField;
public readonly save : Button;
private editedId : string;
constructor() {
super();
this.nameField = new TextField();
this.save = new Button();
this.nameField.Required(true);
}
public getEditedId() : string {
return this.editedId;
}
public Open($data : IProductView) : void {
this.editedId = ObjectValidator.IsEmptyOrNull($data) ? "" : $data.id;
this.nameField.ResetValidation();
this.nameField.Value(ObjectValidator.IsEmptyOrNull($data) ? "" : $data.name);
this.Visible(true);
}
protected innerHtml() : string {
return `
<div class="modal fade" role="dialog" tabindex="-1">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body">
<input class="form-control" type="text" data-oidis-bind="${this.nameField}" />
<div class="invalid-feedback">Name is required</div>
</div>
<div class="modal-footer">
<button class="btn btn-light" type="button" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="button" data-oidis-bind="${this.save}">Save</button>
</div>
</div>
</div>
</div>`;
}
}
// Controller (beforeLoad)
EventsBinding.SingleClick($instance.dialog.save, async () : Promise<void> => {
if (!$instance.dialog.nameField.Validate()) {
return;
}
try {
await this.client.SaveProduct({
id: $instance.dialog.getEditedId(), isActive: true, name: $instance.dialog.nameField.Value(), price: 0
});
} catch (ex) {
LogIt.Error("Failed to save product", ex);
this.ShowMessage("Failed to save product", ToastType.ERROR);
return;
}
$instance.dialog.Visible(false);
await $instance.table.ShowData();
});
Reference: Dialogs | Events and Validation
Dual Access: LCP + REST
Expose the same method via both LCP (for the frontend app) and REST (for external integrations):
@Extern()
@Route("GET /api/products?offset=int(0)&limit=int(50)")
public async ListProducts($offset : number, $limit : number) : Promise<IModelListResult> {
// Same logic for both access paths
}
- Frontend app calls via
asyncInvoke("ListProducts", offset, limit)(LCP) - External clients call via
GET /api/products?offset=0&limit=50(REST)
Both paths go through the same AuthManager permission check.
Reference: LCP & RPC — Dual Access | @Route
Extending Hub Features
Hub provides base implementations that solution projects extend. The pattern is consistent:
- Extend the backend class — override methods, add new
@Externendpoints - Register symbol mapping (only if overriding a hub class) — so LCP resolves to your version
- Extend the frontend panel/controller — add custom UI and bind new data
Example: adding a custom field to Application Settings.
Reference: Extending Hub GUI | Application Settings
See also: Panels and Controllers | LCP & RPC | Models Overview | Troubleshooting