List outlets
Sometimes an outlet represents a region that holds many similar items — a list of currencies, a feed of cards, a table of search results. Plugster supports this directly: any outlet that declares data-child-templates becomes a list outlet, and the framework attaches a small API to it for building, reading, moving and removing items. This page is the reference for that API.
Declaring a list outlet #
A list outlet is a regular outlet that also carries the data-child-templates attribute. The attribute value is a JSON array of one or more URLs pointing to static HTML files — the row templates the framework will instantiate per item.
<div data-controller-name="ExchangeRatesPlugster">
<ul data-outlet-id="ratesList"
data-child-templates='["rate-row-template.html"]'></ul>
</div>
The row template is plain HTML. It uses data-child-outlet-id — not data-outlet-id — to declare its inner outlets:
<!-- rate-row-template.html -->
<li>
<span data-child-outlet-id="currencyCodeLabel"></span>:
<span data-child-outlet-id="valueLabel"></span>
</li>
Two important constraints on row templates:
-
They are static. Plugster fetches them as plain HTML; the server's template engine does not process them. Helpers like
{{ _() }}or{{ render_snippet() }}are not available inside a row template. Localizable strings inside list items must be applied from the controller viatranslateTo, after the item is built. -
The framework caches them. Each template URL is fetched once per page and the response is stored in
Plugster.childTemplateHtmlCache. Concurrent requests for the same URL share a single in-flight jQueryjqXHRviaPlugster.childTemplateRequestCache. If multiple Plugsters reference the same URL in theirdata-child-templates, only the first triggers a network request.
Multiple row templates #
Some lists need more than one row shape — a "normal" row and a "deleted" row, for example. Pass multiple URLs in the JSON array and the controller picks which one to use per item via the first argument to buildListItem (the template index):
<ul data-outlet-id="ratesList"
data-child-templates='["rate-row-normal.html", "rate-row-deleted.html"]'>
</ul>
Template index 0 uses the first URL, index 1 uses the second, and so on. The shapes can differ freely — the framework will compile each template's inner data-child-outlet-id elements independently.
buildListItem
#
The full signature:
listOutlet.buildListItem(
templateIndex, // which child template to instantiate
key, // unique string identifier for the item
data, // arbitrary payload kept alongside the item
outletsSchema, // map of child-outlet name → outlet config
atIndex = 0, // optional, where in the list to insert
itemClickCallback // optional, fires when the row is clicked
);
Typical usage from a controller:
invalidateRatesList(forCurrency) {
let self = this;
self._.selectedCurrencyLabel.text(forCurrency);
self.exchangeRatesSvcs.getLatest(forCurrency).then(function (response) {
self._.ratesList.clear();
Object.keys(response.rates).forEach(function (currencyCode) {
let rate = response.rates[currencyCode];
let itemData = { currencyCode, rate };
let itemOutlets = self._.ratesList.buildListItem(
0,
currencyCode,
itemData,
{ currencyCodeLabel: {}, valueLabel: {} }
);
if (!itemOutlets) return;
itemOutlets.currencyCodeLabel.text(currencyCode);
itemOutlets.valueLabel.text(rate);
});
});
}
buildListItem returns an outlets object that mirrors the schema you passed in, plus a special root outlet pointing at the row element itself. The handles are scoped to that one item — addressing itemOutlets.valueLabel only ever touches that item's valueLabel, never any other row's.
If the key already exists in the list, buildListItem returns null instead of building. Treat null as a signal to skip — typically by an early return as shown above — rather than as an error.
Positioning new items #
The atIndex argument controls where the new item lands. The default is 0 — i.e. new items go at the beginning of the list. When you insert at a position that is already occupied, every item at or after that position shifts forward by one, and each item's tracked index updates accordingly.
self._.ratesList.buildListItem(0, 'eur', eurData, schema); // index 0
self._.ratesList.buildListItem(0, 'usd', usdData, schema, 1); // index 1, after eur
self._.ratesList.buildListItem(0, 'cop', copData, schema, 1); // inserts at 1; usd shifts to 2
Reading items #
The list outlet exposes a small read API for the items it holds:
self._.ratesList.count(); // number of items currently in the list
self._.ratesList.getData('eur'); // { currencyCode: 'EUR', rate: ... } or null if missing
self._.ratesList.getOutlets('eur'); // scoped outlets handle (incl. root) or null if missing
self._.ratesList.getItems(); // dictionary keyed by item key — iteration order NOT guaranteed
self._.ratesList.getItemsAsArray(); // array sorted by tracked index — use this when order matters
The distinction between getItems() and getItemsAsArray() is the one trap worth flagging: getItems() hands back a plain dictionary, and JavaScript dictionary iteration is not guaranteed to match the visual order in the DOM. If your code cares about list order — exporting items in the order they appear, finding the next item after some key, comparing against another sorted list — use getItemsAsArray():
self._.ratesList.getItemsAsArray().forEach((item) => {
console.log(item.index, item.data, item.outlets);
});
Updating and moving items #
setData(key, data) replaces the stored data for one item without rebuilding the row. The DOM is not touched — if the data drives visible state, the controller is responsible for re-rendering the row outlets that reflect it.
self._.ratesList.setData('eur', { currencyCode: 'EUR', rate: newRate });
self._.ratesList.getOutlets('eur').valueLabel.text(newRate);
moveItem(key, direction) swaps the item identified by key with its neighbor at currentIndex + direction — typically +1 or -1. The DOM and the tracked indices update together, atomically. The method throws if the key is unknown or if the resulting position would fall outside [0, count).
self._.ratesList.moveItem('cop', +1); // swap cop with the item below it
self._.ratesList.moveItem('cop', -1); // swap cop with the item above it
Removing items #
self._.ratesList.delete('eur'); // removes one item by key
self._.ratesList.clear(); // removes all items at once
Both methods detach the DOM nodes and drop the bookkeeping for the items they remove. Surrounding items' tracked index values are renumbered so that getItemsAsArray() remains contiguous.
API at a glance #
buildListItem(templateIndex, key, data, outletsSchema, atIndex?, itemClickCallback?)
→ creates the item; returns scoped outlets or null
count() → number of items
setData(key, data) → replace one item's data
getData(key) → read one item's data, or null
getOutlets(key) → scoped outlets handle, or null
getItems() → dictionary keyed by item key (UNORDERED)
getItemsAsArray() → array sorted by index — use when DOM order matters
moveItem(key, direction) → swap with neighbor at currentIndex + direction
delete(key) → remove one item
clear() → remove all items
When to use list outlets #
List outlets are the right tool when the items in a region share structure and the controller manages their lifecycle individually (insert, update, reorder, remove). They are not the right tool for one-off conditional rendering — if you have two or three fixed sub-regions that show or hide based on state, a regular outlet plus show()/hide() is simpler.
For lists that grow and shrink based on data — the dominant case — list outlets are how the framework expects you to work, and the API above is intentionally small enough to keep in your head.