Computed list columns: `IListColumnProvider`
Updated
A model can offer columns for another dictionary's list that are computed, not stored: the price of a price type, the stock in a store. The user adds them in Column setup → Computed; the list asks the model for the values of the loaded rows in one request. Short recipe: Add computed list columns.
Why a seam and not a field#
A stored DefaultSalePrice on the item does not scale: a second price type
would need a third field, a store a column per store, and every copy would go
stale the day the price changed. The values already live elsewhere — the price
history and the stock register. The list only has to ask for them.
| Need | A stored field | A computed column |
|---|---|---|
| A new price type | a new field, a schema change, a release | a new record in PriceType, appears in the setup |
| A new store | a new field | a new record in Store |
| Price changes on 1 June | someone updates the field | the column reads the history on the date |
| Calculated type (base − 7 %) | a copy that drifts | IPricingService quotes it |
The contract#
ZuloOne.Runtime.Services.IListColumnProvider, implemented on a model
service (the same pattern as IFiscalCalendar):
public partial class ItemListColumns : IListColumnProvider
{
private readonly ISqlService _sql;
public ItemListColumns(ISqlService sql) => _sql = sql;
public IReadOnlyList<ListColumnDefinition> ColumnsFor(string objectName)
=> objectName == "Item" ? ItemColumns : [];
public async Task<IReadOnlyDictionary<Guid, object?>> ValuesAsync(
string objectName, string key, Guid? parameter,
IReadOnlyList<Guid> ids, DateTime asOf, CancellationToken ct = default)
=> key switch
{
"Price" => parameter is Guid type ? await PricesAsync(type, ids, asOf) : new Dictionary<Guid, object?>(),
"Stock" => await StockAsync(parameter, ids, asOf),
_ => new Dictionary<Guid, object?>(),
};
}
A ListColumnDefinition describes one column:
| Property | Meaning | Example |
|---|---|---|
Key |
stable key inside the provider | Price, Stock |
Caption |
caption by language, en is the fallback |
{ en: "Price", uk: "Ціна" } |
ParameterDictionary |
the dictionary whose records parameterize the column; null for a column without a parameter |
PriceType, Store |
ParameterOptional |
the column also makes sense with no parameter | true for Stock (all stores) |
AllCaption |
caption of the no-parameter variant | { en: "all stores" } |
ValueType |
Decimal, Integer, String, Boolean, DateTime |
Decimal |
Scale |
decimals shown for Decimal |
2 for money, 3 for quantity |
Rules for ValuesAsync:
- it is called once per column per page, never per row — read the whole
idslist in one query (or in chunks of a few hundred); - answer with a dictionary keyed by row id; a missing id renders empty;
- do not throw for "no value"; throw only for a fault. The platform reports the fault on that column alone, the rest of the list loads;
asOfis a date: prices valid that day, stock at its end. The list sends today.
Every implementation is asked, so Inventory can offer price and stock while
another model offers reserves on the same list. The provider is named by its
implementation type (ItemListColumns).
Constructor: platform managers only#
ServiceRegistry.ResolveAllImplementingAsync creates the provider with
ActivatorUtilities. Inject platform services (ISqlService,
IDictionaryManager<T>); resolve other model services lazily inside the
method with ScriptServices.Get<IPricingService>(). A model service in the
constructor is not in the DI container: the provider cannot be created, its
columns disappear from the Computed group, and the log has
List column provider ItemListColumns could not be created. Other providers
still answer.
Worked example#
The stand has two price types (Retail, and Wholesale = Retail − 7 %) and two stores. The user ticks four columns. The list loads 50 items and sends one request:
POST /api/data/Item/list-columns
{ "ids": ["…50 ids…"],
"columns": [
{ "provider": "ItemListColumns", "key": "Price", "parameter": "6c1e…retail" },
{ "provider": "ItemListColumns", "key": "Price", "parameter": "0b7d…wholesale" },
{ "provider": "ItemListColumns", "key": "Stock", "parameter": "a3f0…storeA" },
{ "provider": "ItemListColumns", "key": "Stock", "parameter": null } ] }
{ "values": {
"ItemListColumns|Price|6c1e…retail": { "e1d2…": 429.00 },
"ItemListColumns|Price|0b7d…wholesale": { "e1d2…": 398.97 },
"ItemListColumns|Stock|a3f0…storea": { "e1d2…": 12 },
"ItemListColumns|Stock|*": { "e1d2…": 16 } },
"errors": {} }
The column key is provider|key|parameter, with the parameter as a lowercase
GUID and * for no parameter. A removed column comes back in errors
(«Колонки «Price» більше немає»), not as a failed request.
Text columns#
A column with ValueType = "String" is left-aligned. Its values may still be
numbers: ItemListColumns' «Characteristic» column answers a number for an
Integer / Decimal characteristic (so the grid sorts 2 before 10) and text for
the rest. The grid formats such a number in the user's locale.
Endpoints and limits#
| Endpoint | Returns |
|---|---|
GET /api/data/{table}/list-columns |
the catalogue: provider, key, caption, parameterDictionary, parameterOptional, allCaption, valueType, scale |
POST /api/data/{table}/list-columns |
{ values, errors } for { ids, asOf?, columns } |
- Both check Read on the dictionary, like the list itself.
- At most 1000 ids (distinct, non-empty) and 30 columns per call; the rest are ignored.
- A provider that cannot be created, or whose
ColumnsForthrows, loses its columns in the catalogue and is logged; the other providers still answer.
In the SPA#
frontend/src/windows/calcColumns.ts holds the catalogue and values queries;
RefColumns.tsx draws the Computed group in Column setup, one checkbox per
parameter record plus the «all» variant. A chosen column is saved with the
user's column setup as { kind: 'calc', provider, key, parameter, label, valueType, scale } — personal, like the rest of the setup. Values are
right-aligned and formatted with scale.
No screen calls the analytic-columns endpoint (a per-dictionary
AnalyticColumns script). Computed columns go through IListColumnProvider.
What's not there yet#
- No filter or sort on a computed column: the values exist only for the loaded page.
- No date picker: the list always asks for today.
- Only dictionary lists; document journals do not ask providers.
- Not exported to Excel with the list.
Where next#
- Add computed list columns: the recipe.
- Prices: what the Price column shows, and adding the columns in Column setup.
- Forms: cookbook: the HTML/JS widget the item card uses
for its price board — and why it has no top-level
await.