nexus-shared 2.0.0 → 3.1.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 (176) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -21
  3. package/README.md +1084 -903
  4. package/dist/Client.Index.d.ts +14 -0
  5. package/dist/Client.Index.js +14 -0
  6. package/dist/Components/Chats/Chat.d.ts +28 -0
  7. package/dist/Components/Chats/Chat.js +19 -0
  8. package/dist/Components/Chats/ChatButton.d.ts +26 -0
  9. package/dist/Components/Chats/ChatButton.js +41 -0
  10. package/dist/Components/Chats/ChatComposer.d.ts +41 -0
  11. package/dist/Components/Chats/ChatComposer.js +177 -0
  12. package/dist/Components/Chats/ChatConversations.d.ts +35 -0
  13. package/dist/Components/Chats/ChatConversations.js +38 -0
  14. package/dist/Components/Chats/ChatPanel.d.ts +66 -0
  15. package/dist/Components/Chats/ChatPanel.js +94 -0
  16. package/dist/Components/Chats/ChatParts.d.ts +87 -0
  17. package/dist/Components/Chats/ChatParts.js +96 -0
  18. package/dist/Components/Chats/ChatThread.d.ts +22 -0
  19. package/dist/Components/Chats/ChatThread.js +96 -0
  20. package/dist/Components/Documents/Button.d.ts +6 -1
  21. package/dist/Components/Documents/Button.js +2 -2
  22. package/dist/Components/Documents/Menu.js +44 -28
  23. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  24. package/dist/Components/Documents/SplitButton.js +12 -8
  25. package/dist/Components/Documents/TabButtons.d.ts +29 -8
  26. package/dist/Components/Documents/TabButtons.js +24 -5
  27. package/dist/Components/Forms/ApiForm.d.ts +6 -4
  28. package/dist/Components/Forms/ApiForm.js +15 -14
  29. package/dist/Components/Forms/Crud.js +342 -71
  30. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  31. package/dist/Components/Forms/CrudParts.js +37 -5
  32. package/dist/Components/Forms/ExcelImport.d.ts +58 -0
  33. package/dist/Components/Forms/ExcelImport.js +319 -0
  34. package/dist/Components/Forms/Form.js +9 -13
  35. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  36. package/dist/Components/Forms/SubmitForm.js +23 -12
  37. package/dist/Components/Inputs/DateTimePicker.js +2 -1
  38. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  39. package/dist/Components/Inputs/DropdownParts.js +4 -6
  40. package/dist/Components/Inputs/GroupForm.js +4 -2
  41. package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
  42. package/dist/Components/Inputs/InputRenderer.js +1 -1
  43. package/dist/Components/Inputs/NumberBox.js +24 -2
  44. package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
  45. package/dist/Components/Inputs/RowsInput.d.ts +12 -1
  46. package/dist/Components/Inputs/RowsInput.js +55 -6
  47. package/dist/Components/Inputs/Slider.js +4 -1
  48. package/dist/Components/Inputs/TabularForm.d.ts +7 -3
  49. package/dist/Components/Inputs/TabularForm.js +117 -20
  50. package/dist/Components/Inputs/TimePanel.js +4 -3
  51. package/dist/Components/Inputs/TimePicker.js +2 -1
  52. package/dist/Components/Layouts/Popup.js +1 -4
  53. package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
  54. package/dist/Components/Layouts/ThemePicker.js +77 -6
  55. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  56. package/dist/Components/Layouts/ThemeScript.js +4 -2
  57. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  58. package/dist/Components/Layouts/ThemeSwitcher.js +39 -18
  59. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  60. package/dist/Components/Viewers/Avatar.js +21 -0
  61. package/dist/Components/Viewers/DataTable.js +262 -60
  62. package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
  63. package/dist/Components/Viewers/DataTableColumns.js +200 -0
  64. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  65. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  66. package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
  67. package/dist/Components/Viewers/DataTableParts.js +24 -12
  68. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  69. package/dist/Helpers/AnimationHelpers.js +20 -2
  70. package/dist/Helpers/ApiClient.d.ts +22 -0
  71. package/dist/Helpers/ApiClient.js +4 -1
  72. package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
  73. package/dist/Helpers/ApiFormHelpers.js +73 -9
  74. package/dist/Helpers/ApiModules.d.ts +0 -4
  75. package/dist/Helpers/ApiModules.js +1 -0
  76. package/dist/Helpers/ApiResponses.d.ts +14 -4
  77. package/dist/Helpers/ApiResponses.js +116 -25
  78. package/dist/Helpers/ApiRoutes.d.ts +0 -10
  79. package/dist/Helpers/ApiRoutes.js +2 -10
  80. package/dist/Helpers/ChatBackend.d.ts +134 -0
  81. package/dist/Helpers/ChatBackend.js +332 -0
  82. package/dist/Helpers/ChatHelpers.d.ts +189 -0
  83. package/dist/Helpers/ChatHelpers.js +486 -0
  84. package/dist/Helpers/ChatHooks.d.ts +81 -0
  85. package/dist/Helpers/ChatHooks.js +175 -0
  86. package/dist/Helpers/ChatStore.d.ts +132 -0
  87. package/dist/Helpers/ChatStore.js +394 -0
  88. package/dist/Helpers/ChatSummaries.d.ts +39 -0
  89. package/dist/Helpers/ChatSummaries.js +118 -0
  90. package/dist/Helpers/CrudBackend.d.ts +54 -9
  91. package/dist/Helpers/CrudBackend.js +103 -6
  92. package/dist/Helpers/CrudHelpers.d.ts +39 -4
  93. package/dist/Helpers/CrudHelpers.js +76 -11
  94. package/dist/Helpers/ExcelBackend.d.ts +106 -0
  95. package/dist/Helpers/ExcelBackend.js +296 -0
  96. package/dist/Helpers/ExcelHelpers.d.ts +126 -0
  97. package/dist/Helpers/ExcelHelpers.js +344 -0
  98. package/dist/Helpers/FormStore.d.ts +11 -5
  99. package/dist/Helpers/FormStore.js +44 -6
  100. package/dist/Helpers/MessageBuilder.js +1 -2
  101. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  102. package/dist/Helpers/NumberHelpers.js +41 -3
  103. package/dist/Helpers/PopoverHelpers.d.ts +46 -9
  104. package/dist/Helpers/PopoverHelpers.js +163 -24
  105. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  106. package/dist/Helpers/PopoverHooks.js +62 -0
  107. package/dist/Helpers/RowsStore.d.ts +14 -3
  108. package/dist/Helpers/RowsStore.js +37 -3
  109. package/dist/Helpers/TableColumns.d.ts +7 -0
  110. package/dist/Helpers/TableColumns.js +41 -13
  111. package/dist/Helpers/TableExport.js +12 -12
  112. package/dist/Helpers/TableFilters.d.ts +25 -0
  113. package/dist/Helpers/TableFilters.js +162 -0
  114. package/dist/Helpers/TableHelpers.d.ts +15 -4
  115. package/dist/Helpers/TableHelpers.js +28 -4
  116. package/dist/Helpers/TableStore.d.ts +11 -3
  117. package/dist/Helpers/TableStore.js +16 -5
  118. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  119. package/dist/Helpers/ThemeHelpers.js +46 -9
  120. package/dist/Helpers/UserPreferences.d.ts +14 -0
  121. package/dist/Helpers/UserPreferences.js +99 -0
  122. package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
  123. package/dist/Interfaces/ApiInterfaces.js +16 -10
  124. package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
  125. package/dist/Interfaces/ChatInterfaces.js +1 -0
  126. package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
  127. package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
  128. package/dist/Interfaces/ExcelInterfaces.js +45 -0
  129. package/dist/Interfaces/FormInterfaces.d.ts +85 -7
  130. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  131. package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
  132. package/dist/Interfaces/PreferenceInterfaces.js +28 -0
  133. package/dist/Interfaces/TableInterfaces.d.ts +116 -9
  134. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  135. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  136. package/dist/Services/BrowserApi.d.ts +18 -1
  137. package/dist/Services/BrowserApi.js +18 -0
  138. package/dist/Services/ChatApi.d.ts +37 -0
  139. package/dist/Services/ChatApi.js +98 -0
  140. package/dist/Services/ExcelApi.d.ts +48 -0
  141. package/dist/Services/ExcelApi.js +196 -0
  142. package/dist/Services/ServerApi.d.ts +6 -1
  143. package/dist/Services/ServerApi.js +3 -0
  144. package/dist/Services/ThemeService.d.ts +18 -2
  145. package/dist/Services/ThemeService.js +80 -16
  146. package/dist/Shared.Index.d.ts +10 -0
  147. package/dist/Shared.Index.js +10 -0
  148. package/package.json +85 -82
  149. package/src/Styles/Nexus.Avatar.css +79 -0
  150. package/src/Styles/Nexus.Base.css +117 -70
  151. package/src/Styles/Nexus.Button.css +180 -151
  152. package/src/Styles/Nexus.Calendar.css +273 -275
  153. package/src/Styles/Nexus.Chat.css +1443 -0
  154. package/src/Styles/Nexus.Choice.css +343 -345
  155. package/src/Styles/Nexus.Crud.css +168 -84
  156. package/src/Styles/Nexus.DateTime.css +81 -81
  157. package/src/Styles/Nexus.Dropdown.css +725 -774
  158. package/src/Styles/Nexus.Editor.css +295 -297
  159. package/src/Styles/Nexus.Excel.css +389 -0
  160. package/src/Styles/Nexus.Form.css +279 -298
  161. package/src/Styles/Nexus.Index.css +37 -34
  162. package/src/Styles/Nexus.Input.css +602 -637
  163. package/src/Styles/Nexus.Markdown.css +237 -265
  164. package/src/Styles/Nexus.Menu.css +237 -249
  165. package/src/Styles/Nexus.Popup.css +724 -731
  166. package/src/Styles/Nexus.RichText.css +2 -32
  167. package/src/Styles/Nexus.Rows.css +637 -566
  168. package/src/Styles/Nexus.Slider.css +276 -278
  169. package/src/Styles/Nexus.Tab.Buttons.css +432 -364
  170. package/src/Styles/Nexus.Table.css +1386 -1014
  171. package/src/Styles/Nexus.Theme.Picker.css +286 -159
  172. package/src/Styles/Nexus.Themes.css +194 -194
  173. package/src/Styles/Nexus.Time.css +144 -147
  174. package/src/Styles/Nexus.Toast.css +263 -265
  175. package/src/Styles/Nexus.Tokens.css +89 -72
  176. package/src/Styles/Nexus.Tree.css +431 -435
@@ -0,0 +1,344 @@
1
+ import { ExcelDataType, ExcelRowKind } from "../Interfaces/ExcelInterfaces.js";
2
+ import { toIsoDate } from "./DateHelpers.js";
3
+ import { toIsoTime } from "./TimeHelpers.js";
4
+ /*
5
+ * Turning a sheet's columns into inputs, a verify's rows into editable rows and back, and handing the person a file.
6
+ * Nothing here calls anything: the calls are in Services/ExcelApi.ts.
7
+ *
8
+ * Two rules decide every reading below, and both come from the backend:
9
+ * - every value travels as **text**, the way it reads in the sheet, because one wire shape serves a verify and an
10
+ * import and the import reads it with the same parser that read the file;
11
+ * - a choice travels as its **key** and never as an id, so a reference cell is shown as the words the person picked
12
+ * and the key beside it is carried through untouched.
13
+ */
14
+ /** What a spreadsheet file is called in a file picker. */
15
+ export const EXCEL_FILE_ACCEPT = ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
16
+ /** The field of an editor row that holds the sheet row it came from. Not a column, so no input is drawn for it. */
17
+ export const EXCEL_ROW_NUMBER = "__excelRow";
18
+ /** The field of an editor row that holds each reference column's key, which the person never sees or types. */
19
+ export const EXCEL_ROW_KEYS = "__excelKeys";
20
+ /** The field of an editor row that holds what the verify answered for it, so an edit can be told from an untouched row. */
21
+ export const EXCEL_ROW_SENT = "__excelSent";
22
+ /* Texts of the spreadsheet side. One table, so a word is changed or translated in one place. */
23
+ const DEFAULT_TEXT = {
24
+ template: "Download template",
25
+ templateHint: "The file to fill in: every list is a dropdown, and the ids beside a choice fill themselves.",
26
+ choose: "Choose file",
27
+ change: "Choose another file",
28
+ checking: "Checking",
29
+ nothingPicked: "No file picked yet.",
30
+ verifyFailed: "The file could not be read.",
31
+ newRows: "New rows",
32
+ duplicates: "Already there",
33
+ duplicateHint: "Already records: these are skipped, and nothing here is updated.",
34
+ failedHead: "Not written",
35
+ skippedHead: "Skipped: already there",
36
+ importAgain: "To import another file, start over.",
37
+ addAgain: "To add more, start over.",
38
+ bulkUnavailable: "The sheet's columns could not be read, so there is nothing to type the rows into.",
39
+ discardTitle: "Discard what you typed?",
40
+ discardLabel: "Discard",
41
+ keepEditing: "Keep editing",
42
+ toFix: "What needs fixing",
43
+ whyDuplicate: "Why these are already there",
44
+ referenceLocked: "Picked in the spreadsheet: this list belongs to the server.",
45
+ generated: "The file fills this in.",
46
+ importLabel: "Import",
47
+ importNothing: "There is no row to import.",
48
+ importedNone: "Nothing was written.",
49
+ refused: "Not written",
50
+ exportWarning: "Export is cut short",
51
+ close: "Close",
52
+ startOver: "Start over",
53
+ };
54
+ let texts = { ...DEFAULT_TEXT };
55
+ /** Rewords or translates the spreadsheet side's own texts. Names only what changes. */
56
+ export function configureExcelText(changes) {
57
+ texts = { ...texts, ...changes };
58
+ }
59
+ /** One of the spreadsheet side's texts. */
60
+ export function excelText(key) {
61
+ return texts[key];
62
+ }
63
+ /* Columns as inputs */
64
+ /** How wide a column is drawn, by what it holds. The template sizes its own columns the same way. */
65
+ const COLUMN_WIDTH = {
66
+ [ExcelDataType.Text]: "12rem",
67
+ [ExcelDataType.Note]: "20rem",
68
+ [ExcelDataType.WholeNumber]: "8rem",
69
+ [ExcelDataType.Decimal]: "8rem",
70
+ [ExcelDataType.Amount]: "9rem",
71
+ [ExcelDataType.Date]: "9.5rem",
72
+ [ExcelDataType.DateTime]: "12rem",
73
+ [ExcelDataType.Time]: "7.5rem",
74
+ [ExcelDataType.YesNo]: "7rem",
75
+ [ExcelDataType.List]: "16rem",
76
+ };
77
+ /** The shapes a zone-less moment may be written in, as the backend reads them. */
78
+ const MOMENT_PATTERN = {
79
+ source: "[0-9]{4}[-/][0-9]{2}[-/][0-9]{2}([ T][0-9]{2}:[0-9]{2}(:[0-9]{2})?)?",
80
+ message: "Write a date and time as 2026-09-29 14:30.",
81
+ };
82
+ const YES_NO = [
83
+ { value: "Yes", label: "Yes" },
84
+ { value: "No", label: "No" },
85
+ ];
86
+ /**
87
+ * One column of a sheet as an input of the review editors. The rules are the ones the sheet told the person about and
88
+ * no others, so a fix made here is checked the way the file was.
89
+ *
90
+ * Three columns are read-only on purpose:
91
+ * - a **reference**, because its list is the backend's and is picked in the spreadsheet; the cell shows the words the
92
+ * person picked and the key travels back untouched (a key read out of an answer is never typed, and never an id);
93
+ * - a column the **file** fills;
94
+ * - and a **date and time** is a text box rather than a picker, because the sheet's moment is a zone-less wall clock
95
+ * while the picker's value is a UTC moment: a picker here would shift every value by the person's own offset.
96
+ */
97
+ export function excelColumnInput(column) {
98
+ const shared = {
99
+ name: column.field,
100
+ label: column.header,
101
+ width: COLUMN_WIDTH[column.dataType] ?? "12rem",
102
+ ...(column.note ? { hint: column.note } : {}),
103
+ };
104
+ const required = column.isRequired === true;
105
+ const lengths = {
106
+ ...(column.minLength && column.minLength > 0 ? { minLength: column.minLength } : {}),
107
+ ...(column.maxLength && column.maxLength > 0 ? { maxLength: column.maxLength } : {}),
108
+ };
109
+ const limits = {
110
+ ...(column.minimum === null || column.minimum === undefined ? {} : { min: column.minimum }),
111
+ ...(column.maximum === null || column.maximum === undefined ? {} : { max: column.maximum }),
112
+ };
113
+ if (column.isReference)
114
+ return { ...shared, type: "text", readOnly: true, readOnlyMessage: excelText("referenceLocked") };
115
+ if (column.isReadOnly)
116
+ return { ...shared, type: "text", readOnly: true, readOnlyMessage: excelText("generated") };
117
+ switch (column.dataType) {
118
+ case ExcelDataType.Note:
119
+ return { ...shared, type: "textarea", rows: 2, required, ...lengths };
120
+ case ExcelDataType.WholeNumber:
121
+ return { ...shared, type: "number", required, decimals: 0, ...limits };
122
+ case ExcelDataType.Decimal:
123
+ return { ...shared, type: "number", required, ...limits };
124
+ case ExcelDataType.Amount:
125
+ return { ...shared, type: "number", required, decimals: 2, fixedDecimals: true, grouping: true, ...limits };
126
+ case ExcelDataType.Date:
127
+ return { ...shared, type: "date", required };
128
+ case ExcelDataType.DateTime:
129
+ return { ...shared, type: "text", required, pattern: MOMENT_PATTERN, placeholder: "2026-09-29 14:30" };
130
+ case ExcelDataType.Time:
131
+ return { ...shared, type: "time", required };
132
+ case ExcelDataType.YesNo:
133
+ return { ...shared, type: "dropdown", required, options: YES_NO, searchable: false };
134
+ default:
135
+ return { ...shared, type: "text", required, ...lengths };
136
+ }
137
+ }
138
+ /** Every column of a sheet as the inputs of one editor row, in the order they read. */
139
+ export function excelColumnInputs(columns) {
140
+ return columns.map(excelColumnInput);
141
+ }
142
+ /* Rows, both ways */
143
+ /** Whether the person edits this column's cell at all, or only reads it. */
144
+ export function isExcelColumnEditable(column) {
145
+ return column.isReference !== true && column.isReadOnly !== true;
146
+ }
147
+ /**
148
+ * A verify's rows as the editors' rows: a value per column, the sheet's row number, and - held beside them, never
149
+ * drawn - each reference column's key and the values the verify answered with.
150
+ *
151
+ * A reference cell shows the **words** the person picked, because its key is not something anybody reads or types.
152
+ */
153
+ export function excelEditorRows(rows, columns) {
154
+ return rows.map(row => {
155
+ const keys = {};
156
+ const editor = { [EXCEL_ROW_NUMBER]: row.number, [EXCEL_ROW_KEYS]: keys, [EXCEL_ROW_SENT]: { ...row.values } };
157
+ for (const column of columns) {
158
+ const value = row.values[column.field] ?? null;
159
+ if (column.isReference) {
160
+ keys[column.field] = value;
161
+ editor[column.field] = row.labels?.[column.field] ?? "";
162
+ continue;
163
+ }
164
+ editor[column.field] = toInputValue(column, value);
165
+ }
166
+ return editor;
167
+ });
168
+ }
169
+ /** Which types are drawn by a number box, whose empty value is `null`. */
170
+ const NUMBER_TYPES = new Set([ExcelDataType.WholeNumber, ExcelDataType.Decimal, ExcelDataType.Amount]);
171
+ /**
172
+ * A cell's text as the input that draws it wants it: a number as a number, a choice unset rather than empty, a date
173
+ * and a time as the inputs hold them, and everything else as it reads.
174
+ *
175
+ * The three empties are not interchangeable. A number box is empty at `null`; a **dropdown** given `""` treats it as a
176
+ * value and reports it as one its list no longer offers ("Not found"), so an empty choice has to be left unset; a text
177
+ * or date box is empty at `""`.
178
+ *
179
+ * A date and a time are read the way their inputs read them: the sheet's "09:30:00" is the time box's "09:30". Read
180
+ * any other way, a cell nobody touched would compare as an edit - and the panel would ask before closing over it.
181
+ */
182
+ function toInputValue(column, value) {
183
+ if (value === null || value === "")
184
+ return NUMBER_TYPES.has(column.dataType) ? null : column.dataType === ExcelDataType.YesNo ? undefined : "";
185
+ switch (column.dataType) {
186
+ case ExcelDataType.WholeNumber:
187
+ case ExcelDataType.Decimal:
188
+ case ExcelDataType.Amount: {
189
+ const number = Number(value.replace(/,/g, ""));
190
+ return Number.isFinite(number) ? number : null;
191
+ }
192
+ case ExcelDataType.YesNo: {
193
+ const upper = value.trim().toUpperCase();
194
+ if (upper === "YES" || upper === "Y" || upper === "TRUE" || upper === "1")
195
+ return "Yes";
196
+ if (upper === "NO" || upper === "N" || upper === "FALSE" || upper === "0")
197
+ return "No";
198
+ return value;
199
+ }
200
+ case ExcelDataType.Date:
201
+ return toIsoDate(value) ?? value;
202
+ case ExcelDataType.Time:
203
+ return toIsoTime(value) ?? value;
204
+ default:
205
+ return value;
206
+ }
207
+ }
208
+ /** A cell as it travels: text, the way it reads in the sheet. A value the editors could not hold is sent back as it came. */
209
+ function toSheetValue(value) {
210
+ if (value === undefined || value === null)
211
+ return null;
212
+ if (typeof value === "boolean")
213
+ return value ? "Yes" : "No";
214
+ const written = String(value).trim();
215
+ return written.length ? written : null;
216
+ }
217
+ /**
218
+ * Whether a cell still holds what the verify answered for it.
219
+ *
220
+ * It is not a comparison of texts, because a cell goes through an input on the way: a sheet that read `38450.50` comes
221
+ * back from a number box as `38450.5`, and `TRUE` comes back from the Yes/No choice as `Yes`. Both are the same value
222
+ * and neither is an edit, so the two are compared the way the input holds them.
223
+ */
224
+ function isSameCell(column, current, sent) {
225
+ const asSheet = toSheetValue(current);
226
+ if (asSheet === (sent ?? null))
227
+ return true;
228
+ return toInputValue(column, asSheet) === toInputValue(column, sent ?? null);
229
+ }
230
+ /** The sheet row a row came from, or 0 for a row added in the browser. */
231
+ export function excelRowNumber(row) {
232
+ const number = Number(row[EXCEL_ROW_NUMBER]);
233
+ return Number.isFinite(number) && number > 0 ? number : 0;
234
+ }
235
+ /**
236
+ * The editors' rows as rows to send: every value as text, every reference as the key it came with, and the sheet's own
237
+ * row number kept so a problem can still name where it is.
238
+ *
239
+ * A cell the person did not touch goes back **exactly** as it came, which is what makes the round trip faithful: the
240
+ * backend reads its own text again, not a number an input rewrote on the way through.
241
+ *
242
+ * A row added in the browser has no sheet row, so it is numbered after the last one the sheet had: no two rows share a
243
+ * number, and a problem still names the row it is about. `after` is that last row - the file's, rows already there and
244
+ * rows taken out of the review included, which are not among the rows sent. A bulk insert, with no sheet at all,
245
+ * numbers its rows 1, 2, 3.
246
+ */
247
+ export function excelSheetRows(rows, columns, after = 0) {
248
+ let next = rows.reduce((last, row) => Math.max(last, excelRowNumber(row)), Math.max(0, after));
249
+ return rows.map(row => {
250
+ const keys = row[EXCEL_ROW_KEYS] ?? {};
251
+ const sent = row[EXCEL_ROW_SENT] ?? {};
252
+ const values = {};
253
+ const labels = {};
254
+ for (const column of columns) {
255
+ if (column.isReference) {
256
+ values[column.field] = keys[column.field] ?? null;
257
+ labels[column.field] = toSheetValue(row[column.field]);
258
+ continue;
259
+ }
260
+ values[column.field] = isSameCell(column, row[column.field], sent[column.field]) ? (sent[column.field] ?? null) : toSheetValue(row[column.field]);
261
+ }
262
+ return { number: excelRowNumber(row) || ++next, values, labels };
263
+ });
264
+ }
265
+ /** Whether the person changed anything in a row since the verify answered it. */
266
+ export function isExcelRowEdited(row, columns) {
267
+ const sent = row[EXCEL_ROW_SENT] ?? {};
268
+ for (const column of columns) {
269
+ if (!isExcelColumnEditable(column))
270
+ continue;
271
+ if (!isSameCell(column, row[column.field], sent[column.field]))
272
+ return true;
273
+ }
274
+ return false;
275
+ }
276
+ /** Whether a row has anything in a cell the person types in. A row added and left empty has not, and is not sent. */
277
+ export function hasExcelValues(row, columns) {
278
+ return columns.some(column => isExcelColumnEditable(column) && toSheetValue(row[column.field]) !== null);
279
+ }
280
+ /**
281
+ * Whether a row holds something only this screen has, which closing it would lose: a sheet's row the person edited, or
282
+ * a row added here with anything in it.
283
+ */
284
+ export function isExcelRowTyped(row, columns) {
285
+ return excelRowNumber(row) > 0 ? isExcelRowEdited(row, columns) : hasExcelValues(row, columns);
286
+ }
287
+ /* What is wrong, in the person's words */
288
+ /**
289
+ * Where a problem is, as the person reads it: the cell, a middle dot, and the column's heading ("B7 - City name"). Rows
290
+ * typed in the browser have no sheet, so `byRow` names the row instead ("Row 3 - City name").
291
+ */
292
+ export function excelProblemPlace(problem, byRow = false) {
293
+ const place = byRow ? `Row ${problem.row}` : problem.cell;
294
+ return place ? `${place} ${String.fromCharCode(183)} ${problem.header}` : problem.header;
295
+ }
296
+ /** Every problem of these rows, in the order the rows read. */
297
+ export function excelProblems(rows) {
298
+ return rows.flatMap(row => row.problems ?? []);
299
+ }
300
+ /** The rows of one kind. */
301
+ export function excelRowsOfKind(rows, ...kinds) {
302
+ return rows.filter(row => kinds.includes(row.kind));
303
+ }
304
+ /** The rows an import would try to write: the new ones, and the ones still waiting on a fix. */
305
+ export function excelWritableRows(rows) {
306
+ return excelRowsOfKind(rows, ExcelRowKind.New, ExcelRowKind.Invalid);
307
+ }
308
+ /** More than one of the subject: "cities", "boxes", "rows". An English ending, which a text change can replace. */
309
+ export function excelPlural(subject, plural) {
310
+ if (plural)
311
+ return plural;
312
+ if (/(s|x|z|ch|sh)$/i.test(subject))
313
+ return `${subject}es`;
314
+ if (/[^aeiou]y$/i.test(subject))
315
+ return `${subject.slice(0, -1)}ies`;
316
+ return `${subject}s`;
317
+ }
318
+ /** A count said in the subject's own words: "1 city", "12 cities". */
319
+ export function excelCount(rows, subject, plural) {
320
+ if (rows === 1)
321
+ return `1 ${subject}`;
322
+ return `${rows.toLocaleString("en-US")} ${excelPlural(subject, plural)}`;
323
+ }
324
+ /**
325
+ * What to tell the person before an export that will be cut short, as the owner asked: what is on screen, what will be
326
+ * written, and that filtering is how they get the rest.
327
+ */
328
+ export function excelExportWarning(rows, limit, subject) {
329
+ return `The search on screen leaves ${excelCount(rows, subject)}, and one export writes at most ${limit.toLocaleString("en-US")}. The first ${limit.toLocaleString("en-US")} are written and the rest are left out. Search or filter for the rows you need to get them all.`;
330
+ }
331
+ /* The file itself */
332
+ /** Hands the person a file to save, under the name the answer gave it. */
333
+ export function saveExcelFile(download) {
334
+ const url = URL.createObjectURL(download.blob);
335
+ const link = document.createElement("a");
336
+ link.href = url;
337
+ link.download = download.fileName;
338
+ link.rel = "noopener";
339
+ document.body.append(link);
340
+ link.click();
341
+ link.remove();
342
+ // Firefox needs the object URL a moment longer than the click.
343
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
344
+ }
@@ -25,7 +25,7 @@ export interface FormStatus {
25
25
  changed: boolean;
26
26
  /** A submit is running. */
27
27
  submitting: boolean;
28
- /** The values are loading: the inputs show placeholders. */
28
+ /** The values are loading: the inputs show, disabled, and take nothing typed until the values are in. */
29
29
  loading: boolean;
30
30
  /** A submit ran and found problems: every message shows. */
31
31
  showAll: boolean;
@@ -85,6 +85,8 @@ export declare class FormStore<TValues extends FormValues = FormValues> {
85
85
  private draftSeq;
86
86
  /** The user cancelled: no draft is written until they change a value again. */
87
87
  private draftDropped;
88
+ /** An input reported a value while the values loaded and was refused: it is drawn again from the store when loading ends. */
89
+ private refusedWhileLoading;
88
90
  /** A load during a render tells nobody; `announce` does, once the form has mounted. */
89
91
  private quiet;
90
92
  private unannounced;
@@ -137,13 +139,15 @@ export declare class FormStore<TValues extends FormValues = FormValues> {
137
139
  * Loads values, such as a record from a server. They become the values the form compares with, and every input shows
138
140
  * them. With `keepChanged`, the values the user changed stay (a refresh that merges): only the others change, and the
139
141
  * names returned are the changed values whose loaded ones also changed, so the page can say so. `draft: false` leaves
140
- * a saved draft for a later load (the form's placeholders show while the real values load). `quiet` tells no listener
141
- * until `announce()`, for a load during a render.
142
+ * a saved draft for a later load (the form shows disabled while the real values load). `quiet` tells no listener
143
+ * until `announce()`, for a load during a render. `loading` opens the form loading, so whatever reads its status in
144
+ * the same render — a `FormButtons` after it — draws its loading state from the first frame, on the server too.
142
145
  */
143
- load(values?: Partial<TValues>, { keepChanged, draft, quiet }?: {
146
+ load(values?: Partial<TValues>, { keepChanged, draft, quiet, loading }?: {
144
147
  keepChanged?: boolean;
145
148
  draft?: boolean;
146
149
  quiet?: boolean;
150
+ loading?: boolean;
147
151
  }): string[];
148
152
  /** Tells listeners about a quiet load. */
149
153
  announce(): void;
@@ -197,7 +201,7 @@ export declare class FormStore<TValues extends FormValues = FormValues> {
197
201
  commit(saved: TValues, extra?: Partial<TValues>): void;
198
202
  /** @internal A save after which the form went back to its opening values (`resetOnSuccess`). */
199
203
  markSaved(): void;
200
- /** @internal */
204
+ /** @internal While the values load, nothing typed is taken (`input`), and a submit waits (`SubmitForm`). */
201
205
  setLoading(loading: boolean): void;
202
206
  /** @internal */
203
207
  setFormId(formId: string | null): void;
@@ -209,6 +213,8 @@ export declare class FormStore<TValues extends FormValues = FormValues> {
209
213
  /** Inputs that were not there, or were hidden, get their value in the input's type, as a load gives it. */
210
214
  private adoptNewInputs;
211
215
  private change;
216
+ /** The inputs whose `dependsOn` names any of `names`. */
217
+ private followersOf;
212
218
  private checkRule;
213
219
  private checkAllRules;
214
220
  /** Runs the form's own rule; returns the inputs whose message from it changed. */
@@ -70,6 +70,8 @@ export class FormStore {
70
70
  draftSeq = 0;
71
71
  /** The user cancelled: no draft is written until they change a value again. */
72
72
  draftDropped = false;
73
+ /** An input reported a value while the values loaded and was refused: it is drawn again from the store when loading ends. */
74
+ refusedWhileLoading = false;
73
75
  /** A load during a render tells nobody; `announce` does, once the form has mounted. */
74
76
  quiet = false;
75
77
  unannounced = false;
@@ -216,14 +218,18 @@ export class FormStore {
216
218
  * Loads values, such as a record from a server. They become the values the form compares with, and every input shows
217
219
  * them. With `keepChanged`, the values the user changed stay (a refresh that merges): only the others change, and the
218
220
  * names returned are the changed values whose loaded ones also changed, so the page can say so. `draft: false` leaves
219
- * a saved draft for a later load (the form's placeholders show while the real values load). `quiet` tells no listener
220
- * until `announce()`, for a load during a render.
221
+ * a saved draft for a later load (the form shows disabled while the real values load). `quiet` tells no listener
222
+ * until `announce()`, for a load during a render. `loading` opens the form loading, so whatever reads its status in
223
+ * the same render — a `FormButtons` after it — draws its loading state from the first frame, on the server too.
221
224
  */
222
- load(values = {}, { keepChanged = false, draft = true, quiet = false } = {}) {
225
+ load(values = {}, { keepChanged = false, draft = true, quiet = false, loading } = {}) {
223
226
  this.quiet = quiet;
224
227
  this.unannounced ||= quiet;
225
228
  try {
226
- return this.loadValues(values, keepChanged, draft);
229
+ const changed = this.loadValues(values, keepChanged, draft);
230
+ if (loading !== undefined)
231
+ this.patchStatus({ loading });
232
+ return changed;
227
233
  }
228
234
  finally {
229
235
  this.quiet = false;
@@ -264,8 +270,10 @@ export class FormStore {
264
270
  this.nextRevision(params.name);
265
271
  }
266
272
  else {
273
+ // Every input mounts again with its loaded value, one that was typed in while loading included.
267
274
  this.generation++;
268
275
  this.touched.clear();
276
+ this.refusedWhileLoading = false;
269
277
  }
270
278
  this.serverErrors.clear();
271
279
  this.serverMessage = null;
@@ -321,6 +329,12 @@ export class FormStore {
321
329
  input(name, value, stamp) {
322
330
  if (stamp !== this.stamp(name))
323
331
  return;
332
+ // Nothing is taken while the values load, not even from an input whose `disabled` was taken off by hand: the
333
+ // loaded values would land on top of it, and a change to a record that is not there yet means nothing.
334
+ if (this.status.loading) {
335
+ this.refusedWhileLoading = true;
336
+ return;
337
+ }
324
338
  this.change([[name, value]], "input");
325
339
  }
326
340
  /** Marks an input as left by the user: the form's rule messages on it show from now on. */
@@ -481,8 +495,14 @@ export class FormStore {
481
495
  markSaved() {
482
496
  this.patchStatus({ submitting: false, savedAt: Date.now() });
483
497
  }
484
- /** @internal */
498
+ /** @internal While the values load, nothing typed is taken (`input`), and a submit waits (`SubmitForm`). */
485
499
  setLoading(loading) {
500
+ // Something was typed and refused, and no load has drawn the inputs again since: they show the store's values.
501
+ if (!loading && this.refusedWhileLoading) {
502
+ this.refusedWhileLoading = false;
503
+ this.generation++;
504
+ this.bumpAll();
505
+ }
486
506
  this.patchStatus({ loading });
487
507
  }
488
508
  /** @internal */
@@ -572,12 +592,19 @@ export class FormStore {
572
592
  write(field, value, "code");
573
593
  }
574
594
  }
595
+ // Inputs that follow a changed one (`dependsOn`) draw again with their params read afresh, and their rule is asked
596
+ // again: a getter may have just made one required, read-only or hidden.
597
+ const followers = this.followersOf(written);
598
+ for (const name of followers) {
599
+ remember(name);
600
+ this.checkRule(name);
601
+ }
575
602
  // The form's rule runs again: an input it now names, or no longer names, shows the change even if nobody typed in it.
576
603
  for (const name of this.checkCross())
577
604
  if (!shown.has(name))
578
605
  shown.set(name, this.shownWith(name, crossBefore));
579
606
  for (const [name, before] of shown)
580
- if (remounted.has(name) || this.shownError(name) !== before)
607
+ if (remounted.has(name) || followers.has(name) || this.shownError(name) !== before)
581
608
  this.bump(name);
582
609
  this.patchStatus({ changed: this.changedNames.size > 0, message: this.formMessage(), problems: this.problemList() });
583
610
  this.emit(source === "input" ? written[0] : written.length === 1 ? written[0] : null);
@@ -586,6 +613,17 @@ export class FormStore {
586
613
  this.draftDropped = false;
587
614
  this.scheduleDraft();
588
615
  }
616
+ /** The inputs whose `dependsOn` names any of `names`. */
617
+ followersOf(names) {
618
+ const found = new Set();
619
+ if (!names.length)
620
+ return found;
621
+ const changed = new Set(names);
622
+ for (const params of this.options.inputs)
623
+ if (params.dependsOn?.some(name => changed.has(name)))
624
+ found.add(params.name);
625
+ return found;
626
+ }
589
627
  checkRule(name) {
590
628
  const params = this.inputByName.get(name);
591
629
  if (params && isRowsInput(params)) {
@@ -11,7 +11,6 @@ const DEFAULT_WORDS = {
11
11
  update: { verb: "update", done: "updated", doing: "Updating", object: "the record", sendsValues: true },
12
12
  delete: { verb: "delete", done: "deleted", doing: "Deleting", object: "the record" },
13
13
  trash: { verb: "move {object} to the trash", done: "moved to the trash", doing: "Moving {object} to the trash", object: "the record" },
14
- archive: { verb: "archive", done: "archived", doing: "Archiving", object: "the record" },
15
14
  restore: { verb: "restore", done: "restored", doing: "Restoring", object: "the record" },
16
15
  recover: { verb: "recover", done: "recovered", doing: "Recovering", object: "the record" },
17
16
  flag: { verb: "flag", done: "flagged", doing: "Flagging", object: "the record" },
@@ -44,7 +43,7 @@ const DEFAULT_TEXTS = {
44
43
  confirmDetails: {
45
44
  delete: "This cannot be undone.",
46
45
  trash: "It can be restored from the trash.",
47
- archive: "It can be restored from the archive.",
46
+ recover: "It goes back to the active list.",
48
47
  },
49
48
  reasons: {
50
49
  network: "The server cannot be reached. Check your internet connection and try again.",
@@ -1,4 +1,5 @@
1
1
  import type { NumberFormatOptions } from "../Interfaces/InputInterfaces.ts";
2
+ import { ENumberScales, type ThousandsSeparator } from "../Interfaces/PreferenceInterfaces.ts";
2
3
  /** Default locale for number text. Fixed rather than the browser's, so server-rendered text matches the browser's. */
3
4
  export declare const DEFAULT_NUMBER_LOCALE = "en-US";
4
5
  /** Most digits a number field takes. A JavaScript number holds 15 significant digits exactly; beyond that, digits change. */
@@ -15,7 +16,12 @@ export interface NumberTextRules {
15
16
  /** Allows a leading minus sign. */
16
17
  negative: boolean;
17
18
  }
18
- /** The decimal and group separators of a locale. */
19
+ /**
20
+ * The locale for a number scale and a thousands separator: `en-US` (1,234,567.5) and `en-IN` (12,34,567.5) as they are,
21
+ * else a key such as "en-US|.|," (1.234.567,5) that `formatNumber`, `parseNumber` and `getNumberSymbols` read.
22
+ */
23
+ export declare function numberLocale(scale: ENumberScales, thousands: ThousandsSeparator): string;
24
+ /** The decimal and group separators of a locale (or of a key from `numberLocale`). */
19
25
  export declare function getNumberSymbols(locale?: string): NumberSymbols;
20
26
  /**
21
27
  * Formats a number for display: `formatNumber(1250, { decimals: 2, fixedDecimals: true, grouping: true })` gives "1,250.00".
@@ -1,3 +1,4 @@
1
+ import { ENumberScales } from "../Interfaces/PreferenceInterfaces.js";
1
2
  /** Default locale for number text. Fixed rather than the browser's, so server-rendered text matches the browser's. */
2
3
  export const DEFAULT_NUMBER_LOCALE = "en-US";
3
4
  /** Most digits a number field takes. A JavaScript number holds 15 significant digits exactly; beyond that, digits change. */
@@ -9,9 +10,43 @@ const BIDI_MARKS = /[\u061c\u200e\u200f]/g;
9
10
  // U+2212, the typographic minus sign some locales use.
10
11
  const MINUS_SIGN = "\u2212";
11
12
  const SPACES = /\s/g;
12
- /** The decimal and group separators of a locale. */
13
+ /*
14
+ * A person's number settings as a locale: the scale picks where the groups fall (en-US, or en-IN for lakh and crore)
15
+ * and the separator what marks them. A pair Intl already has is that locale; any other is a key of three parts,
16
+ * "en-IN|.|,", that the helpers here read as the base locale's grouping with these two marks. Everything that takes
17
+ * a `locale` (a number box, a table cell, a message) therefore takes the person's settings without knowing of them.
18
+ */
19
+ const SEPARATOR_MARKS = {
20
+ period: { group: ".", decimal: "," },
21
+ space: { group: "\u00a0", decimal: "." },
22
+ none: { group: "", decimal: "." },
23
+ };
24
+ /**
25
+ * The locale for a number scale and a thousands separator: `en-US` (1,234,567.5) and `en-IN` (12,34,567.5) as they are,
26
+ * else a key such as "en-US|.|," (1.234.567,5) that `formatNumber`, `parseNumber` and `getNumberSymbols` read.
27
+ */
28
+ export function numberLocale(scale, thousands) {
29
+ const base = scale === ENumberScales.SouthAsian ? "en-IN" : DEFAULT_NUMBER_LOCALE;
30
+ if (thousands === "comma" || !(thousands in SEPARATOR_MARKS))
31
+ return base;
32
+ const { group, decimal } = SEPARATOR_MARKS[thousands];
33
+ return `${base}|${group}|${decimal}`;
34
+ }
35
+ /** A locale key from `numberLocale`: its base locale and its own marks, or `null` for a plain locale. */
36
+ function customMarks(locale) {
37
+ if (!locale.includes("|"))
38
+ return null;
39
+ const [base = DEFAULT_NUMBER_LOCALE, group = ",", decimal = "."] = locale.split("|");
40
+ return { base, group, decimal };
41
+ }
42
+ /** The decimal and group separators of a locale (or of a key from `numberLocale`). */
13
43
  export function getNumberSymbols(locale = DEFAULT_NUMBER_LOCALE) {
14
44
  let symbols = symbolsByLocale.get(locale);
45
+ const custom = symbols ? null : customMarks(locale);
46
+ if (custom) {
47
+ symbols = { decimal: custom.decimal, group: custom.group };
48
+ symbolsByLocale.set(locale, symbols);
49
+ }
15
50
  if (!symbols) {
16
51
  const parts = new Intl.NumberFormat(locale).formatToParts(1234567.5);
17
52
  symbols = {
@@ -33,9 +68,10 @@ export function formatNumber(value, options = {}) {
33
68
  const maxDecimals = decimals ?? MAX_NUMBER_DIGITS;
34
69
  const minDecimals = fixedDecimals ? maxDecimals : 0;
35
70
  const key = `${locale}|${minDecimals}|${maxDecimals}|${grouping}`;
71
+ const custom = customMarks(locale);
36
72
  let formatter = formatters.get(key);
37
73
  if (!formatter) {
38
- formatter = new Intl.NumberFormat(locale, {
74
+ formatter = new Intl.NumberFormat(custom?.base ?? locale, {
39
75
  numberingSystem: "latn",
40
76
  useGrouping: grouping,
41
77
  minimumFractionDigits: minDecimals,
@@ -44,7 +80,9 @@ export function formatNumber(value, options = {}) {
44
80
  formatters.set(key, formatter);
45
81
  }
46
82
  // Rounded first, the same way as roundDecimal, which also turns -0.001 into 0 rather than "-0".
47
- return formatter.format(roundDecimal(value, maxDecimals)).replace(BIDI_MARKS, "").replace(MINUS_SIGN, "-");
83
+ const text = formatter.format(roundDecimal(value, maxDecimals)).replace(BIDI_MARKS, "").replace(MINUS_SIGN, "-");
84
+ // The base locales write "," between groups and "." before decimals; a key's own marks take their places.
85
+ return custom ? text.replace(/[,.]/g, mark => (mark === "," ? custom.group : custom.decimal)) : text;
48
86
  }
49
87
  /**
50
88
  * Reads a number written in the locale's format: "1,250.50" gives 1250.5, and in `de-DE` "1.250,50" does.