Grid columns: what the user reads is not what the row stores
Updated
Every list, journal and document table part in the app is one control —
StandardGrid (frontend/src/kit/StandardGrid.tsx). A column of that grid has
two projections of the same cell, and getting the second one wrong is how a
GUID reaches the user.
The two projections#
{
title: 'Item',
dataIndex: 'Item',
// What the cell SHOWS. Free to be a React node.
render: (v, row) => <ReferenceValue dictionaryId={dictId} value={row.Item} />,
// What the cell IS, as text. Must be a plain string.
exportText: (row) => refText(dictId, row.Item),
}
render is the visible cell; exportText is the same cell as text. They are
separate because a React node cannot be stringified: the row stores
4834f2c2-33b4-4507-9232-8920965b8fb8, the cell shows «Baguette 300g», and
nothing in between can derive one from the other.
Everything textual reads exportText#
| Path | Reads |
|---|---|
| The cell on screen | render |
| Quick search | exportText |
| Column filter — the Values check-list | exportText |
| Column filter — the Text filters tab | exportText |
| Sorting | the raw value — except a reference (GUID), which sorts by exportText |
| Ctrl/Cmd+C on the active cell | exportText |
| Print list and the CSV / Excel / JSON export | exportText |
The Values check-list popover sizes to the longest label (clampColumnFilterWidth
in frontend/src/kit/columnFilterPanel.ts, 260–520 px) and scrolls vertically
once the list is taller than min(24rem, 60vh). A fixed 260×190 box used to clip
1C metadata names and hide rows without a scrollbar.
The helpers are in frontend/src/kit/columns.ts: textProjectionsOf(columns)
builds the accessor → projection map once, cellTextOf reads a cell as text,
sortKeyOf picks the ordering key. Unit tests: kit/columnText.test.ts,
kit/columnFilterPanel.test.ts.
Warning
A reference column without
exportTextstill looks right on screen — and then its filter offers a list of GUIDs, its quick search never matches the name the user typed, and it sorts by a random-looking hex string. The column is not broken where you see it; it is broken everywhere else.
Getting the text for a reference#
useReferenceTexts (components/ReferenceSelect.tsx) resolves a set of
dictionaries once and hands back a lookup:
const refText = useReferenceTexts(
fields.map((f) => f.referenceDictionaryId).filter((id): id is string => Boolean(id)),
);
// …
exportText: (r) => refText(dictionaryId, r[f.name]),
It returns '' for Guid.Empty, the resolved displayValue once loaded, and
the id itself while the dictionary is still loading or when the record is gone.
Its identity is stable while the loaded items are: callers hang it on
memoized column sets, so a fresh closure on every render would rebuild every
column, and a frozen one would pin the grid to the pre-load snapshot where
every reference still reads as a GUID.
For a document pointer — or any other GUID that names a record or type —
use useDocumentLabelText the same way. labeledText in lib/fields.ts is
the shared print/CSV projection: dictionary reference first, then
resolve-labels for a stored record id (including a String/Guid field that
just happens to hold one), otherwise formatValue.
Related columns (useRelatedColumns) walk the same way: the leaf is formatted
for the screen and for exportText. A Guid leaf without a Reference EDT
still resolves.
A document journal is the same grid. Number, date and subtype stay first;
every header field follows, with the same render / exportText pair as a
dictionary list. Hide extras in View → Pick columns. Related columns of
header references (Warehouse.Name) stay in that picker; they are not the
header itself.
System columns DocumentMetaId and OwnerMetaId resolve too — they point at
another record. MetaId / MovementMetaId / AnalyticSetMetaId stay the raw
id: that is the row's own identity, not a name the user reads.
Sorting is deliberately not projected#
A date column's text is 03.10.2026 and a quantity's is 10; sorting those as
strings puts October before February and 10 before 2. So the sort key stays the
RAW value, and only a reference — whose raw value is a GUID with no meaningful
order — falls back to the projection (sortKeyOf).
Lists do not carry photos#
GET /api/data/{table} does not select Image/Binary bytes
(QueryAsync(..., includeBinary: false)). A 40-row Item page with
Item.Image used to wait on megabytes of base64 — the spinner showed
«0 of N» until the body arrived. The column stays on the row as 1 or
null (the blob exists or not, no TOAST). Cards and the Photo column then
load GET /api/data/{table}/{id}/image/{field}?w=160, which returns a
shrunk JPEG/PNG; the <img> sends the office cookie. The record form
still loads the original via GET /api/data/{table}/{id}.
Where next#
- In-app help: the ⓘ on a column title.
- The forms system: where a list form's columns come from.