nexus-shared 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +704 -446
  2. package/LICENSE +21 -21
  3. package/README.md +44 -5
  4. package/dist/Client.Index.d.ts +3 -0
  5. package/dist/Client.Index.js +3 -0
  6. package/dist/Components/Forms/Crud.js +322 -161
  7. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  8. package/dist/Components/Forms/CrudHistory.js +140 -0
  9. package/dist/Components/Forms/CrudParts.js +2 -1
  10. package/dist/Components/Forms/Form.js +4 -1
  11. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  12. package/dist/Components/Inputs/CodeBox.js +316 -0
  13. package/dist/Components/Inputs/DatePicker.js +4 -1
  14. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  15. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  16. package/dist/Components/Inputs/Dropdown.js +4 -1
  17. package/dist/Components/Inputs/InputRenderer.js +3 -0
  18. package/dist/Components/Inputs/NumberBox.js +4 -2
  19. package/dist/Components/Inputs/TextArea.js +4 -1
  20. package/dist/Components/Inputs/TextBox.js +4 -1
  21. package/dist/Components/Inputs/TimePicker.js +4 -1
  22. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  23. package/dist/Components/Inputs/TreeView.js +372 -23
  24. package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
  25. package/dist/Components/Layouts/ThemePicker.js +16 -4
  26. package/dist/Components/Viewers/DataTable.js +63 -21
  27. package/dist/Components/Viewers/DataTableParts.d.ts +26 -8
  28. package/dist/Components/Viewers/DataTableParts.js +39 -10
  29. package/dist/Helpers/ApiModules.d.ts +3 -34
  30. package/dist/Helpers/ApiModules.js +3 -3
  31. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  32. package/dist/Helpers/ApiRoutes.js +31 -27
  33. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  34. package/dist/Helpers/ArrayHelpers.js +2 -0
  35. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  36. package/dist/Helpers/CodeHelpers.js +332 -0
  37. package/dist/Helpers/CrudBackend.d.ts +94 -4
  38. package/dist/Helpers/CrudBackend.js +168 -9
  39. package/dist/Helpers/CrudColumns.d.ts +51 -0
  40. package/dist/Helpers/CrudColumns.js +69 -0
  41. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  42. package/dist/Helpers/CrudHelpers.js +109 -12
  43. package/dist/Helpers/InputParamsHelpers.js +8 -0
  44. package/dist/Helpers/MessageBuilder.js +2 -0
  45. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  46. package/dist/Helpers/NexusHelpers.js +1 -0
  47. package/dist/Helpers/RequestCache.d.ts +35 -0
  48. package/dist/Helpers/RequestCache.js +113 -0
  49. package/dist/Helpers/TableExport.js +12 -12
  50. package/dist/Helpers/TableHelpers.d.ts +1 -1
  51. package/dist/Helpers/TableHelpers.js +7 -2
  52. package/dist/Helpers/TableJoins.d.ts +70 -0
  53. package/dist/Helpers/TableJoins.js +250 -0
  54. package/dist/Helpers/TableStore.d.ts +5 -0
  55. package/dist/Helpers/TableStore.js +9 -0
  56. package/dist/Helpers/TreeStore.d.ts +225 -11
  57. package/dist/Helpers/TreeStore.js +683 -90
  58. package/dist/Helpers/UserPreferences.d.ts +8 -0
  59. package/dist/Helpers/UserPreferences.js +17 -0
  60. package/dist/Interfaces/CrudInterfaces.d.ts +47 -9
  61. package/dist/Interfaces/FormInterfaces.d.ts +4 -1
  62. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  63. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  64. package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
  65. package/dist/Interfaces/PreferenceInterfaces.js +2 -5
  66. package/dist/Interfaces/TableInterfaces.d.ts +86 -4
  67. package/dist/Services/ApiProxy.d.ts +36 -1
  68. package/dist/Services/ApiProxy.js +182 -52
  69. package/dist/Services/ApiProxyRules.d.ts +203 -0
  70. package/dist/Services/ApiProxyRules.js +426 -0
  71. package/dist/Services/JoinSources.d.ts +48 -0
  72. package/dist/Services/JoinSources.js +74 -0
  73. package/dist/Services/ServerApi.d.ts +13 -2
  74. package/dist/Services/ServerApi.js +8 -3
  75. package/dist/Shared.Index.d.ts +4 -0
  76. package/dist/Shared.Index.js +4 -0
  77. package/package.json +87 -85
  78. package/src/Styles/Nexus.Avatar.css +79 -79
  79. package/src/Styles/Nexus.Base.css +117 -117
  80. package/src/Styles/Nexus.Button.css +180 -180
  81. package/src/Styles/Nexus.Calendar.css +273 -273
  82. package/src/Styles/Nexus.Chat.css +1443 -1443
  83. package/src/Styles/Nexus.Choice.css +343 -343
  84. package/src/Styles/Nexus.Crud.css +168 -168
  85. package/src/Styles/Nexus.DateTime.css +81 -81
  86. package/src/Styles/Nexus.Dropdown.css +725 -725
  87. package/src/Styles/Nexus.Editor.css +295 -295
  88. package/src/Styles/Nexus.Excel.css +389 -389
  89. package/src/Styles/Nexus.Form.css +279 -279
  90. package/src/Styles/Nexus.Index.css +37 -37
  91. package/src/Styles/Nexus.Input.css +196 -4
  92. package/src/Styles/Nexus.Markdown.css +237 -237
  93. package/src/Styles/Nexus.Menu.css +237 -237
  94. package/src/Styles/Nexus.Popup.css +724 -724
  95. package/src/Styles/Nexus.Rows.css +637 -637
  96. package/src/Styles/Nexus.Slider.css +276 -276
  97. package/src/Styles/Nexus.Tab.Buttons.css +432 -432
  98. package/src/Styles/Nexus.Table.css +31 -30
  99. package/src/Styles/Nexus.Theme.Picker.css +59 -3
  100. package/src/Styles/Nexus.Themes.css +194 -194
  101. package/src/Styles/Nexus.Time.css +144 -144
  102. package/src/Styles/Nexus.Toast.css +263 -263
  103. package/src/Styles/Nexus.Tokens.css +89 -89
  104. package/src/Styles/Nexus.Tree.css +500 -431
@@ -17,6 +17,8 @@ const DEFAULT_WORDS = {
17
17
  unflag: { verb: "unflag", done: "unflagged", doing: "Unflagging", object: "the record" },
18
18
  pin: { verb: "pin", done: "pinned", doing: "Pinning", object: "the record" },
19
19
  unpin: { verb: "unpin", done: "unpinned", doing: "Unpinning", object: "the record" },
20
+ favorite: { verb: "add {object} to the favourites", done: "added to the favourites", doing: "Adding {object} to the favourites", object: "the record" },
21
+ unfavorite: { verb: "remove {object} from the favourites", done: "removed from the favourites", doing: "Removing {object} from the favourites", object: "the record" },
20
22
  send: { verb: "send", done: "sent", doing: "Sending", object: "the message", sendsValues: true },
21
23
  submit: { verb: "submit", done: "submitted", doing: "Submitting", object: "the form", sendsValues: true },
22
24
  upload: { verb: "upload", done: "uploaded", doing: "Uploading", object: "the file", sendsValues: true },
File without changes
@@ -0,0 +1 @@
1
+ "use strict";
@@ -0,0 +1,35 @@
1
+ /** How long an answer is kept by default: 5 minutes, the CRUD's and the joins' lifetime too. */
2
+ export declare const REQUEST_CACHE_LIFETIME: number;
3
+ /** Whether a value is a promise, or any object with a `then` (what `await` treats as one). */
4
+ export declare function isPromiseLike<T>(value: unknown): value is PromiseLike<T>;
5
+ export declare class RequestCache<T> {
6
+ private kept;
7
+ readonly lifetime: number;
8
+ private now;
9
+ constructor(lifetime?: number, now?: () => number);
10
+ /** The answer kept under `key` while it is fresh; `undefined` when there is none, it has aged, or it is still on its way. */
11
+ peek(key: string, lifetime?: number): T | undefined;
12
+ /** Whether a request for `key` is on its way. */
13
+ isLoading(key: string): boolean;
14
+ /**
15
+ * The answer under `key`: a fresh kept one at once, the request already on its way, or a new request from `load`.
16
+ * An answer `load` gives at once (not a promise) is kept and given back at once too, so a caller can draw it in the
17
+ * same frame. A failure is not kept. `lifetime` 0 or less shares a request on its way but keeps no answer.
18
+ *
19
+ * @example
20
+ * const page = cache.get(`regions|${parentId}|0`, signal => api.post(children, { parentId, skipCount: 0, maxCount: 50 }, { signal }));
21
+ */
22
+ get(key: string, load: (signal: AbortSignal) => T | PromiseLike<T>, lifetime?: number): T | Promise<T>;
23
+ /** Keeps an answer under `key`, as if it had just been loaded. */
24
+ set(key: string, value: T): void;
25
+ private keep;
26
+ /**
27
+ * Drops the answers - kept or on their way - under every key that starts with `prefix`, or that `test` passes, or
28
+ * every one when nothing is given. A request dropped on its way still answers whoever waits for it; its answer is
29
+ * just not kept. Returns how many were dropped.
30
+ */
31
+ drop(match?: string | ((key: string) => boolean)): number;
32
+ clear(): void;
33
+ /** Answers kept and requests on their way. */
34
+ get size(): number;
35
+ }
@@ -0,0 +1,113 @@
1
+ /*
2
+ * Answers kept by name for a lifetime, shared by every caller on the page: the tree view's pages of children, and any
3
+ * other request whose answer a page asks for more than once. These are the owner's caching rules
4
+ * (Project-Skills/README.RULES.md), the same as `tableJoins` keeps for looked-up records:
5
+ *
6
+ * - an answer is kept for a lifetime (5 minutes by default, like JOIN_LIFETIME and CRUD_CACHE_LIFETIME), so leaving a
7
+ * page and coming back, or a second component asking the same thing, makes no call;
8
+ * - a request already on its way is waited for, never made twice;
9
+ * - a failure is not kept, so the next ask (a Retry) tries again;
10
+ * - a refresh drops what it names and nothing else, so only what is needed now is loaded again.
11
+ *
12
+ * Nothing here knows about React or the API: a caller names the answer and says how to load it. Kept in memory, for the
13
+ * life of the page; the browser database the rules ask for is still to come (Agent-Memory/project-address-joins.md).
14
+ */
15
+ /** How long an answer is kept by default: 5 minutes, the CRUD's and the joins' lifetime too. */
16
+ export const REQUEST_CACHE_LIFETIME = 5 * 60 * 1000;
17
+ /** Whether a value is a promise, or any object with a `then` (what `await` treats as one). */
18
+ export function isPromiseLike(value) {
19
+ return typeof value === "object" && value !== null && typeof value.then === "function";
20
+ }
21
+ export class RequestCache {
22
+ kept = new Map();
23
+ lifetime;
24
+ now;
25
+ constructor(lifetime = REQUEST_CACHE_LIFETIME, now = Date.now) {
26
+ this.lifetime = lifetime;
27
+ this.now = now;
28
+ }
29
+ /** The answer kept under `key` while it is fresh; `undefined` when there is none, it has aged, or it is still on its way. */
30
+ peek(key, lifetime = this.lifetime) {
31
+ const entry = this.kept.get(key);
32
+ if (entry?.state !== "ready")
33
+ return undefined;
34
+ if (this.now() - entry.at >= lifetime) {
35
+ this.kept.delete(key);
36
+ return undefined;
37
+ }
38
+ return entry.value;
39
+ }
40
+ /** Whether a request for `key` is on its way. */
41
+ isLoading(key) {
42
+ return this.kept.get(key)?.state === "loading";
43
+ }
44
+ /**
45
+ * The answer under `key`: a fresh kept one at once, the request already on its way, or a new request from `load`.
46
+ * An answer `load` gives at once (not a promise) is kept and given back at once too, so a caller can draw it in the
47
+ * same frame. A failure is not kept. `lifetime` 0 or less shares a request on its way but keeps no answer.
48
+ *
49
+ * @example
50
+ * const page = cache.get(`regions|${parentId}|0`, signal => api.post(children, { parentId, skipCount: 0, maxCount: 50 }, { signal }));
51
+ */
52
+ get(key, load, lifetime = this.lifetime) {
53
+ const entry = this.kept.get(key);
54
+ if (entry?.state === "loading")
55
+ return entry.promise;
56
+ if (entry?.state === "ready" && this.now() - entry.at < lifetime)
57
+ return entry.value;
58
+ // Nothing aborts a kept request: whoever else waits for it, or comes back for it, still wants the answer.
59
+ const answer = load(new AbortController().signal);
60
+ if (!isPromiseLike(answer)) {
61
+ this.keep(key, answer, lifetime);
62
+ return answer;
63
+ }
64
+ const loading = {
65
+ state: "loading",
66
+ promise: Promise.resolve(answer).then(value => {
67
+ // A drop while it was on its way: the answer goes to whoever waits, and is not kept.
68
+ if (this.kept.get(key) === loading)
69
+ this.keep(key, value, lifetime);
70
+ return value;
71
+ }, (error) => {
72
+ if (this.kept.get(key) === loading)
73
+ this.kept.delete(key);
74
+ throw error;
75
+ }),
76
+ };
77
+ this.kept.set(key, loading);
78
+ return loading.promise;
79
+ }
80
+ /** Keeps an answer under `key`, as if it had just been loaded. */
81
+ set(key, value) {
82
+ this.keep(key, value, this.lifetime);
83
+ }
84
+ keep(key, value, lifetime) {
85
+ if (lifetime > 0)
86
+ this.kept.set(key, { state: "ready", value, at: this.now() });
87
+ else
88
+ this.kept.delete(key);
89
+ }
90
+ /**
91
+ * Drops the answers - kept or on their way - under every key that starts with `prefix`, or that `test` passes, or
92
+ * every one when nothing is given. A request dropped on its way still answers whoever waits for it; its answer is
93
+ * just not kept. Returns how many were dropped.
94
+ */
95
+ drop(match) {
96
+ let dropped = 0;
97
+ for (const key of [...this.kept.keys()]) {
98
+ const hit = match === undefined || (typeof match === "string" ? key.startsWith(match) : match(key));
99
+ if (!hit)
100
+ continue;
101
+ this.kept.delete(key);
102
+ dropped++;
103
+ }
104
+ return dropped;
105
+ }
106
+ clear() {
107
+ this.kept.clear();
108
+ }
109
+ /** Answers kept and requests on their way. */
110
+ get size() {
111
+ return this.kept.size;
112
+ }
113
+ }
@@ -38,18 +38,18 @@ export function exportTableCsv(output) {
38
38
  }
39
39
  const escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
40
40
  /* Plain black on white, since the paper has no theme, and a header row that repeats on every page. */
41
- const PRINT_STYLE = `
42
- * { box-sizing: border-box; }
43
- body { margin: 16mm 12mm; color: #111; font: 11pt/1.45 system-ui, "Segoe UI", sans-serif; }
44
- h1 { margin: 0 0 2mm; font-size: 15pt; }
45
- p.nx-note { margin: 0 0 5mm; color: #555; font-size: 9pt; }
46
- table { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
47
- thead { display: table-header-group; }
48
- th, td { padding: 4px 6px; border-bottom: 1px solid #bbb; text-align: left; vertical-align: top; }
49
- th { border-bottom: 1.5px solid #555; font-weight: 600; white-space: nowrap; }
50
- td.nx-end, th.nx-end { text-align: right; }
51
- td.nx-center, th.nx-center { text-align: center; }
52
- tr { page-break-inside: avoid; }
41
+ const PRINT_STYLE = `
42
+ * { box-sizing: border-box; }
43
+ body { margin: 16mm 12mm; color: #111; font: 11pt/1.45 system-ui, "Segoe UI", sans-serif; }
44
+ h1 { margin: 0 0 2mm; font-size: 15pt; }
45
+ p.nx-note { margin: 0 0 5mm; color: #555; font-size: 9pt; }
46
+ table { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
47
+ thead { display: table-header-group; }
48
+ th, td { padding: 4px 6px; border-bottom: 1px solid #bbb; text-align: left; vertical-align: top; }
49
+ th { border-bottom: 1.5px solid #555; font-weight: 600; white-space: nowrap; }
50
+ td.nx-end, th.nx-end { text-align: right; }
51
+ td.nx-center, th.nx-center { text-align: center; }
52
+ tr { page-break-inside: avoid; }
53
53
  `;
54
54
  /** The print view's HTML: a heading, a note, and the table. */
55
55
  export function tableToPrintHtml({ columns, rows, cellOptions = {}, name = "Table", note }) {
@@ -11,7 +11,7 @@ export declare const DEFAULT_MAX_SELECTION = 500;
11
11
  export declare function readPath(row: unknown, path: string): unknown;
12
12
  /** A row's key as text, so 5 and "5" are the same row. */
13
13
  export declare function rowKeyText<TRow extends TableRow>(row: TRow, rowKey: string | ((row: TRow) => TableKey)): string;
14
- /** A column's value in a row. */
14
+ /** A column's value in a row: its own `value`, the record a `join` looked up, or the field. */
15
15
  export declare function columnValue<TRow extends TableRow>(column: TableColumn<TRow>, row: TRow): unknown;
16
16
  /** The option a value stands for, if the column has options. Values compare as text, so 2 matches "2". */
17
17
  export declare function columnOption(column: TableColumn<never> | TableColumn, value: unknown): TableColumnOption | undefined;
@@ -3,6 +3,7 @@ import { formatDateTime } from "./DateTimeHelpers.js";
3
3
  import { foldSearchText, searchWords } from "./DropdownHelpers.js";
4
4
  import { formatNumber } from "./NumberHelpers.js";
5
5
  import { filterMatcher } from "./TableFilters.js";
6
+ import { joinedValue } from "./TableJoins.js";
6
7
  import { formatTime } from "./TimeHelpers.js";
7
8
  /*
8
9
  * Data table helpers, with no React: reading a column's value, its text, searching, sorting, and paging rows, and the
@@ -33,9 +34,13 @@ export function rowKeyText(row, rowKey) {
33
34
  const key = typeof rowKey === "function" ? rowKey(row) : readPath(row, rowKey);
34
35
  return key === undefined || key === null ? "" : String(key);
35
36
  }
36
- /** A column's value in a row. */
37
+ /** A column's value in a row: its own `value`, the record a `join` looked up, or the field. */
37
38
  export function columnValue(column, row) {
38
- return column.value ? column.value(row) : readPath(row, column.key);
39
+ if (column.value)
40
+ return column.value(row);
41
+ if (column.join)
42
+ return joinedValue(column.join, column.key, row);
43
+ return readPath(row, column.key);
39
44
  }
40
45
  /** The option a value stands for, if the column has options. Values compare as text, so 2 matches "2". */
41
46
  export function columnOption(column, value) {
@@ -0,0 +1,70 @@
1
+ import type { ApiEndpoint } from "../Interfaces/ApiInterfaces.ts";
2
+ import type { TableJoin, TableJoinSource, TableJoinedRecord, TableRow } from "../Interfaces/TableInterfaces.ts";
3
+ /** How long an answer is kept before the next request asks again: the CRUD's own cache lifetime. */
4
+ export declare const JOIN_LIFETIME: number;
5
+ /** What is known of one key: its record, that its source has none, that it is on its way, or that the call failed. */
6
+ export type JoinedState = {
7
+ status: "ready";
8
+ record: TableJoinedRecord;
9
+ } | {
10
+ status: "missing";
11
+ } | {
12
+ status: "loading";
13
+ } | {
14
+ status: "failed";
15
+ };
16
+ export declare class TableJoins {
17
+ private sources;
18
+ private lists;
19
+ private listeners;
20
+ private version;
21
+ /** Called after every answer, and after answers are marked old. For `useSyncExternalStore`. */
22
+ subscribe: (listener: () => void) => (() => void);
23
+ /** A number that changes with every answer, so a table knows to draw again. */
24
+ getVersion: () => number;
25
+ private notify;
26
+ private cacheOf;
27
+ /**
28
+ * What is known of `key` in the source called `name`, for drawing: its record - an aged one too, until the new one is
29
+ * in - or that it is missing, on its way, or could not be read. A key nobody has asked for yet reads as on its way.
30
+ */
31
+ peek(name: string, key: string): JoinedState;
32
+ /**
33
+ * Asks for every key the cache cannot answer fresh. The keys asked for in one moment - by every table and input on
34
+ * the page - go in one call per source, after the moment is over; a key already on its way is not asked again.
35
+ */
36
+ request(source: TableJoinSource, keys: Iterable<string>): void;
37
+ private queue;
38
+ private send;
39
+ /**
40
+ * The records behind these keys, once they are in: what a picker shows for the values a form already holds. The
41
+ * calls and the answers are the ones the tables share, so a form opened from a table costs nothing more.
42
+ */
43
+ records(source: TableJoinSource, keys: Iterable<string>): Promise<TableJoinedRecord[]>;
44
+ /**
45
+ * A whole list kept under `name` - a picker's options - loaded once per lifetime; a second ask while it loads waits for
46
+ * the same call. A list that failed is not kept, so the next ask tries again.
47
+ */
48
+ list(name: string, load: (signal: AbortSignal) => Promise<readonly TableJoinedRecord[]>, lifetime?: number): Promise<readonly TableJoinedRecord[]>;
49
+ /**
50
+ * Marks answers as old - a source's, some of its keys, or every one - after the records behind them may have changed:
51
+ * a save, a move to the trash, a Reload. They are still drawn, and asked for again at the next request.
52
+ */
53
+ expire(name?: string, keys?: Iterable<string>): void;
54
+ /** Drops a kept list (or every one), so the next ask loads it again. */
55
+ expireList(name?: string): void;
56
+ /** Forgets everything. For tests. */
57
+ clear(): void;
58
+ }
59
+ /** The page's one cache of looked-up records: every table and form shares it. */
60
+ export declare const tableJoins: TableJoins;
61
+ /** The name a source is kept under: its own, or an endpoint's module, method and path. */
62
+ export declare function joinSourceName(source: ApiEndpoint | TableJoinSource): string;
63
+ /** The key a row holds for a join, as text; null when it holds none. */
64
+ export declare function joinKey(join: TableJoin, row: TableRow): string | null;
65
+ /** What is known of the record a row's join names; null when the row names none. */
66
+ export declare function joinedState(join: TableJoin, row: TableRow): JoinedState | null;
67
+ /** A join column's value: the looked-up record's field, or undefined until it is in. */
68
+ export declare function joinedValue(join: TableJoin, columnKey: string, row: TableRow): unknown;
69
+ /** The keys rows hold for every join among these columns, by source: what one request per source asks for. */
70
+ export declare function joinKeys(joins: readonly TableJoin[], rows: readonly TableRow[]): Map<ApiEndpoint | TableJoinSource, Set<string>>;
@@ -0,0 +1,250 @@
1
+ import { isApiEndpoint } from "./ApiRoutes.js";
2
+ // A cycle, and a harmless one: TableHelpers reads a join column through this module, and only calls are made across it.
3
+ import { readPath } from "./TableHelpers.js";
4
+ /*
5
+ * The records a page looks up by key, kept once for every table and form on it: a country's sub-region, named from the
6
+ * sub-regions' FindListing. It is the client side of a join - the database answers the row with the key alone, and the
7
+ * page names it - and these are its rules (the owner's caching rules, Project-Skills/README.RULES.md):
8
+ *
9
+ * - one call per source for every key wanted in the same moment, however many tables and inputs ask;
10
+ * - a call already out is waited for, never made twice;
11
+ * - an answer is kept for a lifetime, and one that has aged is still drawn while the new one is asked for, so a
12
+ * refresh never blanks a cell;
13
+ * - a key the source does not answer is remembered as missing ("Not found"), and a call that failed is not made
14
+ * again for a short while, so a backend that is down is not asked once per render.
15
+ *
16
+ * Nothing here knows about React or the API: a source loads, and whoever draws subscribes. Kept in memory, for the life
17
+ * of the page.
18
+ */
19
+ /** How long an answer is kept before the next request asks again: the CRUD's own cache lifetime. */
20
+ export const JOIN_LIFETIME = 5 * 60 * 1000;
21
+ /** How long a key whose call failed waits before it is asked again. */
22
+ const RETRY_AFTER = 30 * 1000;
23
+ /** The most keys one call carries; more are sent in turn. A Nexus mass action takes 500, and so does a FindListing. */
24
+ const KEYS_PER_CALL = 500;
25
+ const LOADING = { status: "loading" };
26
+ const MISSING = { status: "missing" };
27
+ const FAILED = { status: "failed" };
28
+ export class TableJoins {
29
+ sources = new Map();
30
+ lists = new Map();
31
+ listeners = new Set();
32
+ version = 0;
33
+ /** Called after every answer, and after answers are marked old. For `useSyncExternalStore`. */
34
+ subscribe = (listener) => {
35
+ this.listeners.add(listener);
36
+ return () => {
37
+ this.listeners.delete(listener);
38
+ };
39
+ };
40
+ /** A number that changes with every answer, so a table knows to draw again. */
41
+ getVersion = () => this.version;
42
+ notify() {
43
+ this.version++;
44
+ for (const listener of this.listeners)
45
+ listener();
46
+ }
47
+ cacheOf(name) {
48
+ let cache = this.sources.get(name);
49
+ if (!cache) {
50
+ cache = { entries: new Map(), pending: new Map(), failedAt: new Map(), queued: null };
51
+ this.sources.set(name, cache);
52
+ }
53
+ return cache;
54
+ }
55
+ /**
56
+ * What is known of `key` in the source called `name`, for drawing: its record - an aged one too, until the new one is
57
+ * in - or that it is missing, on its way, or could not be read. A key nobody has asked for yet reads as on its way.
58
+ */
59
+ peek(name, key) {
60
+ const cache = this.sources.get(name);
61
+ const entry = cache?.entries.get(key);
62
+ if (entry)
63
+ return entry.record ? { status: "ready", record: entry.record } : MISSING;
64
+ if (cache?.failedAt.has(key) && !cache.pending.has(key))
65
+ return FAILED;
66
+ return LOADING;
67
+ }
68
+ /**
69
+ * Asks for every key the cache cannot answer fresh. The keys asked for in one moment - by every table and input on
70
+ * the page - go in one call per source, after the moment is over; a key already on its way is not asked again.
71
+ */
72
+ request(source, keys) {
73
+ const cache = this.cacheOf(source.name);
74
+ const lifetime = source.lifetime ?? JOIN_LIFETIME;
75
+ const now = Date.now();
76
+ for (const key of keys) {
77
+ if (!key || cache.pending.has(key))
78
+ continue;
79
+ const entry = cache.entries.get(key);
80
+ if (entry && now - entry.at < lifetime)
81
+ continue;
82
+ const failed = cache.failedAt.get(key);
83
+ if (!entry && failed !== undefined && now - failed < RETRY_AFTER)
84
+ continue;
85
+ this.queue(source, cache, key);
86
+ }
87
+ }
88
+ queue(source, cache, key) {
89
+ let batch = cache.queued;
90
+ if (!batch) {
91
+ let finish = () => { };
92
+ const done = new Promise(resolve => (finish = resolve));
93
+ const queued = { keys: new Set(), done, finish };
94
+ cache.queued = batch = queued;
95
+ queueMicrotask(() => void this.send(source, cache, queued));
96
+ }
97
+ batch.keys.add(key);
98
+ cache.pending.set(key, batch.done);
99
+ }
100
+ async send(source, cache, batch) {
101
+ if (cache.queued === batch)
102
+ cache.queued = null;
103
+ const keys = [...batch.keys];
104
+ const keyField = source.key ?? "id";
105
+ let answered = 0;
106
+ try {
107
+ for (let start = 0; start < keys.length; start += KEYS_PER_CALL) {
108
+ const part = keys.slice(start, start + KEYS_PER_CALL);
109
+ const records = await source.load(part, new AbortController().signal);
110
+ const found = new Map();
111
+ for (const record of records) {
112
+ const key = record[keyField];
113
+ if (key !== undefined && key !== null)
114
+ found.set(String(key), record);
115
+ }
116
+ const at = Date.now();
117
+ for (const key of part) {
118
+ cache.entries.set(key, { record: found.get(key) ?? null, at });
119
+ cache.failedAt.delete(key);
120
+ }
121
+ answered += part.length;
122
+ }
123
+ }
124
+ catch {
125
+ // A key with an older answer keeps it; one with none is "could not be read", and waits before it is asked again.
126
+ const at = Date.now();
127
+ for (const key of keys.slice(answered))
128
+ if (!cache.entries.has(key))
129
+ cache.failedAt.set(key, at);
130
+ }
131
+ finally {
132
+ for (const key of keys)
133
+ if (cache.pending.get(key) === batch.done)
134
+ cache.pending.delete(key);
135
+ batch.finish();
136
+ this.notify();
137
+ }
138
+ }
139
+ /**
140
+ * The records behind these keys, once they are in: what a picker shows for the values a form already holds. The
141
+ * calls and the answers are the ones the tables share, so a form opened from a table costs nothing more.
142
+ */
143
+ async records(source, keys) {
144
+ const wanted = [...new Set([...keys].filter(Boolean))];
145
+ this.request(source, wanted);
146
+ const cache = this.cacheOf(source.name);
147
+ await Promise.all(wanted.map(key => cache.pending.get(key)).filter(Boolean));
148
+ const found = [];
149
+ for (const key of wanted) {
150
+ const record = cache.entries.get(key)?.record;
151
+ if (record)
152
+ found.push(record);
153
+ }
154
+ return found;
155
+ }
156
+ /**
157
+ * A whole list kept under `name` - a picker's options - loaded once per lifetime; a second ask while it loads waits for
158
+ * the same call. A list that failed is not kept, so the next ask tries again.
159
+ */
160
+ list(name, load, lifetime = JOIN_LIFETIME) {
161
+ const kept = this.lists.get(name);
162
+ if (kept?.pending)
163
+ return kept.pending;
164
+ if (kept?.rows && Date.now() - kept.at < lifetime)
165
+ return Promise.resolve(kept.rows);
166
+ const pending = load(new AbortController().signal).then(rows => {
167
+ this.lists.set(name, { at: Date.now(), rows });
168
+ return rows;
169
+ }, (error) => {
170
+ this.lists.delete(name);
171
+ throw error;
172
+ });
173
+ this.lists.set(name, { ...kept, at: kept?.at ?? 0, pending });
174
+ return pending;
175
+ }
176
+ /**
177
+ * Marks answers as old - a source's, some of its keys, or every one - after the records behind them may have changed:
178
+ * a save, a move to the trash, a Reload. They are still drawn, and asked for again at the next request.
179
+ */
180
+ expire(name, keys) {
181
+ const caches = name === undefined ? [...this.sources.values()] : [this.sources.get(name)].filter((cache) => Boolean(cache));
182
+ for (const cache of caches) {
183
+ if (keys) {
184
+ for (const key of keys) {
185
+ const entry = cache.entries.get(key);
186
+ if (entry)
187
+ entry.at = 0;
188
+ cache.failedAt.delete(key);
189
+ }
190
+ }
191
+ else {
192
+ for (const entry of cache.entries.values())
193
+ entry.at = 0;
194
+ cache.failedAt.clear();
195
+ }
196
+ }
197
+ if (caches.length)
198
+ this.notify();
199
+ }
200
+ /** Drops a kept list (or every one), so the next ask loads it again. */
201
+ expireList(name) {
202
+ if (name === undefined)
203
+ this.lists.clear();
204
+ else
205
+ this.lists.delete(name);
206
+ }
207
+ /** Forgets everything. For tests. */
208
+ clear() {
209
+ this.sources.clear();
210
+ this.lists.clear();
211
+ this.notify();
212
+ }
213
+ }
214
+ /** The page's one cache of looked-up records: every table and form shares it. */
215
+ export const tableJoins = new TableJoins();
216
+ /** The name a source is kept under: its own, or an endpoint's module, method and path. */
217
+ export function joinSourceName(source) {
218
+ return isApiEndpoint(source) ? `${String(source.module)} ${source.method} ${source.path}` : source.name;
219
+ }
220
+ /** The key a row holds for a join, as text; null when it holds none. */
221
+ export function joinKey(join, row) {
222
+ const key = readPath(row, join.from);
223
+ return key === undefined || key === null || key === "" ? null : String(key);
224
+ }
225
+ /** What is known of the record a row's join names; null when the row names none. */
226
+ export function joinedState(join, row) {
227
+ const key = joinKey(join, row);
228
+ return key === null ? null : tableJoins.peek(joinSourceName(join.source), key);
229
+ }
230
+ /** A join column's value: the looked-up record's field, or undefined until it is in. */
231
+ export function joinedValue(join, columnKey, row) {
232
+ const state = joinedState(join, row);
233
+ return state?.status === "ready" ? readPath(state.record, join.field ?? columnKey) : undefined;
234
+ }
235
+ /** The keys rows hold for every join among these columns, by source: what one request per source asks for. */
236
+ export function joinKeys(joins, rows) {
237
+ const bySource = new Map();
238
+ for (const join of joins) {
239
+ const name = joinSourceName(join.source);
240
+ let entry = bySource.get(name);
241
+ if (!entry)
242
+ bySource.set(name, (entry = { source: join.source, keys: new Set() }));
243
+ for (const row of rows) {
244
+ const key = joinKey(join, row);
245
+ if (key !== null)
246
+ entry.keys.add(key);
247
+ }
248
+ }
249
+ return new Map([...bySource.values()].map(entry => [entry.source, entry.keys]));
250
+ }
@@ -83,6 +83,11 @@ export declare class TableStore<TRow extends TableRow = TableRow> {
83
83
  private fail;
84
84
  /** Searches, filters, sorts, and pages every row (client tables), then tells the table. */
85
85
  private view;
86
+ /**
87
+ * Some columns' values changed while the rows did not: the names a `join` column reads came in. A client table reads
88
+ * its search text again and searches, filters, sorts and pages anew; a server table draws again. Nothing loads.
89
+ */
90
+ refreshValues(): void;
86
91
  /** Changes the query: a client table shows it at once, a server table loads it. */
87
92
  private query;
88
93
  setSearch(search: string): void;
@@ -239,6 +239,15 @@ export class TableStore {
239
239
  query: page === query.page ? query : { ...query, page },
240
240
  });
241
241
  }
242
+ /**
243
+ * Some columns' values changed while the rows did not: the names a `join` column reads came in. A client table reads
244
+ * its search text again and searches, filters, sorts and pages anew; a server table draws again. Nothing loads.
245
+ */
246
+ refreshValues() {
247
+ this.texts = new WeakMap();
248
+ if (this.state.status === "ready")
249
+ this.view();
250
+ }
242
251
  /** Changes the query: a client table shows it at once, a server table loads it. */
243
252
  query(changes) {
244
253
  const query = { ...this.state.query, ...changes };