Version 2.1.0 adds the lookup folder: ILookupSource, a stateless,
request/response source of reference items, for the widgets that ask
questions of an entity on behalf of other data, a combobox searching
departments by name, the lookup column of a grid showing the department of
each row from its id. The release is additive: no existing export
changes, data-source and query-items are untouched.
Until 2.0.0 such widgets used an IDataSource with a FilterExtension:
they wrote their filters into the extension, read, restored the filters,
under a lock, because a data source is a cursor ("the latest read wins", one
items) and a combobox panel, the value of its input and the fifty cells of
a grid page ask different questions of the same entity at the same time. The
readItems hook of 2.0.0 can cache or batch the reading of a cursor, but the
cursor still has one query and one items: the problem was the contract, not
the reader.
ILookupSourceinterface ILookupSource<TItem = Any, TValue = LookupValue> {
search(
query: string,
page: LookupPage,
signal?: AbortSignal,
): Promise<LookupSearchResult<TItem>>;
resolve(values: List<TValue>, signal?: AbortSignal): Promise<List<TItem>>;
valueOf(item: TItem): TValue;
}
type LookupPage = { limit: number; offset: number };
type LookupSearchResult<TItem> = { hasMore: boolean; items: List<TItem> };
type LookupValue = number | string;
Each call has its own parameters, its own result and its own signal: no
events, no items, no lock, no state on the interface, so one instance
serves any number of consumers at once. search pages the items whose text
contains query (every item for ""), by offset and limit, and says
whether more follow. resolve returns the items of the keys given, without
the missing ones, in no particular order: index them with valueOf. The
keys are compared with === everywhere (the in filter, the cache, the
batch), so TValue defaults to number | string and should stay a
primitive.
createLookupSource and createInMemoryLookupSourceconst departments = createLookupSource(readDepartments, {
textField: "name",
valueField: "id",
});
// ILookupSource<Department, number>: the key type is the type of `valueField`
const fixtures = createInMemoryLookupSource(Departments, {
textField: "name",
valueField: "id",
});
createLookupSource(reader, options) builds a source over the same
QueryItemsReader a data source reads through. search sends
textField contains query (no filter for "") with
pagination: { page: offset / limit + 1, itemsPerPage: limit, totalItemsCount: 0 }
(Pagination is 1-based and page-based, the count is unknown): offset
must be a multiple of limit, a panel always pages by its own limit, and
search rejects otherwise. hasMore is items.length >= limit: no count
reader, at the cost of one empty page when the total is a multiple of
limit; asking for one item more than limit would shift the next page, so
it is not done. resolve sends valueField in values without pagination,
and resolves [] without calling the reader for an empty list. There are no
fixed filters: wrap the reader to add a condition to every call.
createInMemoryLookupSource(items | getItems, options) is the same over a
list, through createInMemoryQueryItemsReader of query-items, with its
comparison options (caseSensitive, toComparable), so a list in a story or
a spec behaves like a server.
A service with its own endpoints (search by text, read by key) needs no
factory: an object literal satisfying ILookupSource is a source.
withBatchingconst departments = withBatching(source, { chunkSize: 50 });
// Rendered in the same tick, the cells share one `id in [1, 2]` request.
cell1.show(departments.resolve([1]));
cell2.show(departments.resolve([2]));
cell3.show(departments.resolve([1]));
The fifty cells of a grid page each resolve their own key without knowing of
the others: withBatching coalesces the resolve calls of the same
microtask (queueMicrotask, no timers) into one call of the inner source
with the distinct values, chunkSize values at most per call, and gives each
caller the items of its own values, picked from the shared result with
valueOf. A rejected call of the inner source rejects every caller of the
batch; the next tick opens a new one. search and valueOf pass through.
withCacheconst departments = withCache(withBatching(source));
await departments.resolve([1, 2]); // one request
await departments.resolve([2, 3]); // one request, for 3 only
departments.invalidate([2]); // 2 is asked again next time; no argument forgets everything
The same department is shown in twenty rows and on every page: withCache
remembers the item of every resolved value for the life of the source, only
asks the inner source the values it has never seen, shares a pending
resolution among the callers asking for the same value, remembers a value the
source does not return as missing and forgets a rejected one, so the next
call tries again. It returns an ICachedLookupSource, the source plus
invalidate(values?), which forgets entries by hand, pending ones included.
Compose it outside the batching: the misses of the cache are what the batch
coalesces.
An aborted call rejects with the AbortedException of ts-core, the
rejection of a superseded read of a data source, so one isAbortedException
handles both. The reader-based source hands the signal to the reader as it
is (a fetch cancels the request) and rejects as soon as the signal is
aborted, whatever the reader does with it: the in-memory reader ignores the
signal and still behaves. The two decorators do not forward the signal to
the inner source, since one caller's abort must not starve the others sharing
the batch or the pending resolution: the aborted caller alone rejects, at
once, and the result still fills the cache.
New, from the package root: ILookupSource, ICachedLookupSource,
LookupValue, LookupPage, LookupSearchResult, LookupReaderOptions,
BatchingOptions, createLookupSource, createInMemoryLookupSource,
withBatching, withCache. Nothing else changes.
npm install @mantlebee/ts-data@^2.1.0
Nothing to migrate. Where a combobox or a lookup column drives an
IDataSource through its FilterExtension, build an ILookupSource over
the same reader instead, withCache(withBatching(createLookupSource(reader, { textField, valueField }))),
and call search from the panel and resolve from the cells; the lock and
the save/restore of the filters go away. The data source stays the right
tool for the grid or the list the user browses.