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.
- package/CHANGELOG.md +446 -0
- package/LICENSE +21 -21
- package/README.md +1084 -903
- package/dist/Client.Index.d.ts +14 -0
- package/dist/Client.Index.js +14 -0
- package/dist/Components/Chats/Chat.d.ts +28 -0
- package/dist/Components/Chats/Chat.js +19 -0
- package/dist/Components/Chats/ChatButton.d.ts +26 -0
- package/dist/Components/Chats/ChatButton.js +41 -0
- package/dist/Components/Chats/ChatComposer.d.ts +41 -0
- package/dist/Components/Chats/ChatComposer.js +177 -0
- package/dist/Components/Chats/ChatConversations.d.ts +35 -0
- package/dist/Components/Chats/ChatConversations.js +38 -0
- package/dist/Components/Chats/ChatPanel.d.ts +66 -0
- package/dist/Components/Chats/ChatPanel.js +94 -0
- package/dist/Components/Chats/ChatParts.d.ts +87 -0
- package/dist/Components/Chats/ChatParts.js +96 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/Menu.js +44 -28
- package/dist/Components/Documents/SplitButton.d.ts +3 -1
- package/dist/Components/Documents/SplitButton.js +12 -8
- package/dist/Components/Documents/TabButtons.d.ts +29 -8
- package/dist/Components/Documents/TabButtons.js +24 -5
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +342 -71
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +37 -5
- package/dist/Components/Forms/ExcelImport.d.ts +58 -0
- package/dist/Components/Forms/ExcelImport.js +319 -0
- package/dist/Components/Forms/Form.js +9 -13
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/GroupForm.js +4 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
- package/dist/Components/Inputs/InputRenderer.js +1 -1
- package/dist/Components/Inputs/NumberBox.js +24 -2
- package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +55 -6
- package/dist/Components/Inputs/Slider.js +4 -1
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +117 -20
- package/dist/Components/Inputs/TimePanel.js +4 -3
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/Popup.js +1 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
- package/dist/Components/Layouts/ThemePicker.js +77 -6
- package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
- package/dist/Components/Layouts/ThemeScript.js +4 -2
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
- package/dist/Components/Layouts/ThemeSwitcher.js +39 -18
- package/dist/Components/Viewers/Avatar.d.ts +21 -0
- package/dist/Components/Viewers/Avatar.js +21 -0
- package/dist/Components/Viewers/DataTable.js +262 -60
- package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
- package/dist/Components/Viewers/DataTableColumns.js +200 -0
- package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
- package/dist/Components/Viewers/DataTableFilters.js +68 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
- package/dist/Components/Viewers/DataTableParts.js +24 -12
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- package/dist/Helpers/ApiClient.d.ts +22 -0
- package/dist/Helpers/ApiClient.js +4 -1
- package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
- package/dist/Helpers/ApiFormHelpers.js +73 -9
- package/dist/Helpers/ApiModules.d.ts +0 -4
- package/dist/Helpers/ApiModules.js +1 -0
- package/dist/Helpers/ApiResponses.d.ts +14 -4
- package/dist/Helpers/ApiResponses.js +116 -25
- package/dist/Helpers/ApiRoutes.d.ts +0 -10
- package/dist/Helpers/ApiRoutes.js +2 -10
- package/dist/Helpers/ChatBackend.d.ts +134 -0
- package/dist/Helpers/ChatBackend.js +332 -0
- package/dist/Helpers/ChatHelpers.d.ts +189 -0
- package/dist/Helpers/ChatHelpers.js +486 -0
- package/dist/Helpers/ChatHooks.d.ts +81 -0
- package/dist/Helpers/ChatHooks.js +175 -0
- package/dist/Helpers/ChatStore.d.ts +132 -0
- package/dist/Helpers/ChatStore.js +394 -0
- package/dist/Helpers/ChatSummaries.d.ts +39 -0
- package/dist/Helpers/ChatSummaries.js +118 -0
- package/dist/Helpers/CrudBackend.d.ts +54 -9
- package/dist/Helpers/CrudBackend.js +103 -6
- package/dist/Helpers/CrudHelpers.d.ts +39 -4
- package/dist/Helpers/CrudHelpers.js +76 -11
- package/dist/Helpers/ExcelBackend.d.ts +106 -0
- package/dist/Helpers/ExcelBackend.js +296 -0
- package/dist/Helpers/ExcelHelpers.d.ts +126 -0
- package/dist/Helpers/ExcelHelpers.js +344 -0
- package/dist/Helpers/FormStore.d.ts +11 -5
- package/dist/Helpers/FormStore.js +44 -6
- package/dist/Helpers/MessageBuilder.js +1 -2
- package/dist/Helpers/NumberHelpers.d.ts +7 -1
- package/dist/Helpers/NumberHelpers.js +41 -3
- package/dist/Helpers/PopoverHelpers.d.ts +46 -9
- package/dist/Helpers/PopoverHelpers.js +163 -24
- package/dist/Helpers/PopoverHooks.d.ts +24 -0
- package/dist/Helpers/PopoverHooks.js +62 -0
- package/dist/Helpers/RowsStore.d.ts +14 -3
- package/dist/Helpers/RowsStore.js +37 -3
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableExport.js +12 -12
- package/dist/Helpers/TableFilters.d.ts +25 -0
- package/dist/Helpers/TableFilters.js +162 -0
- package/dist/Helpers/TableHelpers.d.ts +15 -4
- package/dist/Helpers/TableHelpers.js +28 -4
- package/dist/Helpers/TableStore.d.ts +11 -3
- package/dist/Helpers/TableStore.js +16 -5
- package/dist/Helpers/ThemeHelpers.d.ts +21 -4
- package/dist/Helpers/ThemeHelpers.js +46 -9
- package/dist/Helpers/UserPreferences.d.ts +14 -0
- package/dist/Helpers/UserPreferences.js +99 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
- package/dist/Interfaces/ApiInterfaces.js +16 -10
- package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
- package/dist/Interfaces/ChatInterfaces.js +1 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +85 -7
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
- package/dist/Interfaces/PreferenceInterfaces.js +28 -0
- package/dist/Interfaces/TableInterfaces.d.ts +116 -9
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- package/dist/Services/BrowserApi.d.ts +18 -1
- package/dist/Services/BrowserApi.js +18 -0
- package/dist/Services/ChatApi.d.ts +37 -0
- package/dist/Services/ChatApi.js +98 -0
- package/dist/Services/ExcelApi.d.ts +48 -0
- package/dist/Services/ExcelApi.js +196 -0
- package/dist/Services/ServerApi.d.ts +6 -1
- package/dist/Services/ServerApi.js +3 -0
- package/dist/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +10 -0
- package/dist/Shared.Index.js +10 -0
- package/package.json +85 -82
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +117 -70
- package/src/Styles/Nexus.Button.css +180 -151
- package/src/Styles/Nexus.Calendar.css +273 -275
- package/src/Styles/Nexus.Chat.css +1443 -0
- package/src/Styles/Nexus.Choice.css +343 -345
- package/src/Styles/Nexus.Crud.css +168 -84
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +725 -774
- package/src/Styles/Nexus.Editor.css +295 -297
- package/src/Styles/Nexus.Excel.css +389 -0
- package/src/Styles/Nexus.Form.css +279 -298
- package/src/Styles/Nexus.Index.css +37 -34
- package/src/Styles/Nexus.Input.css +602 -637
- package/src/Styles/Nexus.Markdown.css +237 -265
- package/src/Styles/Nexus.Menu.css +237 -249
- package/src/Styles/Nexus.Popup.css +724 -731
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +637 -566
- package/src/Styles/Nexus.Slider.css +276 -278
- package/src/Styles/Nexus.Tab.Buttons.css +432 -364
- package/src/Styles/Nexus.Table.css +1386 -1014
- package/src/Styles/Nexus.Theme.Picker.css +286 -159
- package/src/Styles/Nexus.Themes.css +194 -194
- package/src/Styles/Nexus.Time.css +144 -147
- package/src/Styles/Nexus.Toast.css +263 -265
- package/src/Styles/Nexus.Tokens.css +89 -72
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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.
|