Skip to main content

GUI Pitfalls

Mistakes that compile (or almost compile) but break at runtime or open security holes. Each item is verified against the io-oidis-gui and io-oidis-usercontrols sources.

Label.Text and Content Render HTML​

GuiCommons.Content($value) writes the value through ElementManager.setInnerHtml(), i.e. element.innerHTML. Label.Text(), DropDownList.Text() and DropDownListItem.Text() all delegate to Content(). Every value passed to them is parsed as HTML.

Escape user data

A record name like <img src=x onerror="..."> shown with label.Text($data.Name) runs script in the browser of every user who views it (stored XSS). Escape every value that comes from users or from the database:

export function escapeHtml($value : string) : string {
return String($value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

$item.productName.Text(escapeHtml($data.Name));

The framework has no such helper — keep it in one shared utility module of your project. ValidationBinding.setOnlyQuerySafeAllowed() escapes only what is typed into one input — it does not protect data written by other clients or APIs.

Static markup you build yourself (icons, badges) may still be passed as HTML — escape only the interpolated values: label.Text(`<span class="badge text-bg-success">${escapeHtml($data.Status)}</span>`).

Button Has No Text()​

The Bootstrap Button has no Text() method — set its label with Content():

$instance.saveButton.Content("Save");          // ✅
$instance.saveButton.Text("Save"); // ❌ compile error

Content() is a setter only (returns void). DropDownListItem extends Button and adds its own Text().

Tooltips Are Bootstrap Tooltips, Never a Native title​

A bare title="…" shows the browser's native tooltip, which does not match the Bootstrap UI. Always mark the element for a Bootstrap tooltip:

<button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="tooltip" title="Delete" aria-label="Delete"
data-oidis-bind="${this.remove}">×</button>

GuiCommons initializes new bootstrap.Tooltip(element) for every [data-bs-toggle="tooltip"] element in a component's innerHtml() when the component DOM is created — including table rows and dialogs created later, because each of them is its own component. Markup written afterwards through Label.Text() / Content() is not scanned; initialize those elements yourself:

$item.state.Text(`<span class="badge text-bg-warning" data-bs-toggle="tooltip" title="${escapeHtml($hint)}">locked</span>`);
$item.state.InstanceOwner().querySelectorAll("[data-bs-toggle=\"tooltip\"]").forEach(($element : HTMLElement) : void => {
new bootstrap.Tooltip($element);
});

Keep aria-label on icon-only buttons for screen readers — it does not replace the tooltip.

Label.Text("") Keeps the Previous Value​

Label.Text() stores the value through Property.String, which ignores an empty string, and re-renders the previous text. Use " " to clear a label, or call Content(""). In reused table rows this shows as values leaking between records — see Tables — Restore Pattern.

d-none in the Markup Is Never Removed by Visible()​

Visible() keeps a state and toggles d-none on the bound element; called before the panel is rendered (in resolver()) it only stores the state, which binding applies by adding d-none to a hidden element. A d-none written into the markup is never removed, so Visible(true) cannot show it. Leave the class out of innerHtml() and hide the element by calling Visible(false) in resolver() (as MyProfilePanelController does with the color scheme card); fill and show it from FetchData() or later, when the panel is in the page.

Member Names Clash with CORE Members​

A panel, dialog or row is a subclass of GuiCommons, so its members share one namespace with the private members of the framework base classes. Reusing such a name fails to compile (Class 'X' incorrectly extends base class … Types have separate declarations of a private property) or, for public/protected members, silently replaces framework state.

Declared byMembers
BaseObjectobjectNamespace, objectClassName
GuiCommonsid, instance, children, parent, events, loaded, registered, completed, isWrapper, visible, enabled, active, data, environment, httpManager, lazyLoad
Table<T>localization (protected), content, header, body, pagination, noData, progress, progressValue, items
TableItemtext (public label), styleClasses (protected)
Dialogshown, btModal

Use specific names instead: activeField, contentHolder, productName, statusLabel.

Tab IDs are members too

Page layouts assign every tab controller to a page property named after the tab id (or declaredBy). A tab with the id events overwrites GuiCommons.events and the page fails with this.events.Subscribe is not a function. See Pages and Routing — Tab IDs become properties.

Components Are Created in the Constructor​

Declare UI members without an initializer and create them after super(). Nothing instantiates = undefined members automatically:

export class ProductsPanel extends BasePanel {
public readonly table : ProductsTable;
public readonly addButton : Button;

constructor() {
super();
this.table = new ProductsTable();
this.addButton = new Button();
}
}

EventsBinding Has Only SingleClick​

EventsBinding.Change, DoubleClick, Blur or Focus do not exist. Use the component events manager: getEvents().setOnChange(), setOnDoubleClick(), setOnBlur(), setOnFocus() or setEvent("oninput", …). See Events and Validation.

InvalidFeedback Needs a Feedback Element​

TextField.InvalidFeedback($message) writes into the next sibling .invalid-feedback element, or the first one in the parent. Without such an element in innerHtml() the message is silently dropped. ValidationBinding.setInvalidFeedback() does not exist.

Input Value in Markup Is Overwritten​

Before a component is rendered, TextField.Value() (and every getInputDOM() based accessor) works on a detached virtual <input>. When the component is bound to its element, GuiCommons copies the virtual value over the element value — a value="…" attribute written in innerHtml() is replaced by an empty string. Set the value through the control, before or after rendering:

this.priceField = new TextField();
this.priceField.Value(String($product.price)); // ✅ survives rendering
// <input value="${$product.price}" data-oidis-bind="${this.priceField}" /> ❌ rendered empty

Only value is copied — the checked attribute of a checkbox written in markup is kept.

Controls for a Variable Number of Rows​

GuiCommons binds data-oidis-bind elements only to controls stored in enumerable properties of the component (BaseObject.getProperties() walks for…in). Controls kept only in an array or a Map stay unbound and their clicks do nothing. For layouts driven by data (rows, matrix columns), store every created control in its own property as well:

export abstract class ViewPart extends GuiCommons {
private ownedCount : number = 0;

protected own<T extends GuiCommons>($control : T) : T {
(<any>this)["owned" + this.ownedCount++] = $control;
return $control;
}
}

// constructor: this.rows = $items.map(($item) => ({item: $item, edit: this.own(new Button())}));
// innerHtml(): rows.map(($row) => `<button type="button" data-oidis-bind="${$row.edit}">Edit</button>`)

Build such a part from the data in its constructor, bind the events, then holder.Clear(); holder.AddChild(part).

Bind events before AddChild(), or the last row stays dead

AddChild() ends with Register(), which subscribes only controls whose events manager already exists — it is created by the first getEvents() call. Events bound after AddChild() get subscribed only by the next AddChild() of a sibling, so every row of a loop works except the last one. Order every loop as: create the control → getEvents().setOnClick(…) / EventsBinding.SingleClick(…) → holder.AddChild(control) → DOM-dependent setup (Show(), CSS classes). A flaky click on the last item is this bug, not a double click.

A part holding composite controls must be a BasePanel

AddChild() of a plain GuiCommons marks only the added part as loaded — the controls nested in it never get ON_LOAD / ON_COMPLETE. Simple controls (Button, TextField, Label) do not notice, but every control that sets itself up from ON_COMPLETE breaks: DatePicker, DateTimePicker, TimePicker, YearCalendar, YearStrip and KanbanBoard never initialize (a DatePicker shows an open, empty calendar), DropDownSearch does not close on an outside click, DropDownMultiselect keeps its "select all" hidden. A BasePanel added by AddChild() is rendered by Render(), which runs the lifecycle of all its nested controls:

export class TokenRow extends BasePanel {     // ✅ nested DatePicker initializes
public readonly expire : DatePicker;
// ...
}
// export class TokenRow extends GuiCommons { // ❌ calendar never closes, nested ON_COMPLETE never fires

Parts in the static template of a panel or dialog are rendered the same way and are not affected. The behavior is covered by the DynamicAddChildRuntimeTest runtime test of io-oidis-usercontrols.

AddChild() parses the child markup inside a template element chosen by getDOMTemplateType(): tbody for markup starting with <tr, table for <tbody, otherwise span. Markup that the HTML parser drops in a span needs an override — e.g. a table cell component added to an existing <tr>:

export class MatrixCell extends GuiCommons {
protected getDOMTemplateType() : string {
return "tr";
}

protected innerHtml() : string {
return `<td class="text-end">${text}</td>`;
}
}
Choice lists use DropDownList

Do not build native <select> elements for choices — use DropDownList (or a subclass styled as a form field) so every list looks and behaves like the rest of the Bootstrap UI. See User Controls — DropDownList.

DropDownMultiselect writes the toggle text (localization.emptySelection, fullSelection or the selected items) only when an item is selected or unselected. After Clear() with no items, or when nothing is selected while filling, the toggle keeps its initial text — the generated control id. After filling it, set the text yourself when nothing is selected:

if (!$options.some(($option) : boolean => $selected.includes($option.value))) {
this.Text(this.localization.emptySelection);
}

With an empty item list the base logic would even report "all selected", because no item is unselected.

DialogBinding Closes Only a Single Close Button​

new DialogBinding().Bind({close: [...]}) with an array registers no click handlers — only Esc clicks the first button. Pass a single Button, or hide the dialog yourself. See Dialogs.


See also: Panels and Controllers | Tables | Dialogs | Troubleshooting