nexus-shared 3.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 (121) hide show
  1. package/CHANGELOG.md +446 -262
  2. package/LICENSE +21 -21
  3. package/README.md +1084 -1000
  4. package/dist/Client.Index.d.ts +3 -0
  5. package/dist/Client.Index.js +3 -0
  6. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  7. package/dist/Components/Chats/ChatParts.js +3 -7
  8. package/dist/Components/Chats/ChatThread.js +3 -3
  9. package/dist/Components/Documents/Button.d.ts +6 -1
  10. package/dist/Components/Documents/Button.js +2 -2
  11. package/dist/Components/Documents/Menu.js +21 -9
  12. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  13. package/dist/Components/Documents/SplitButton.js +7 -5
  14. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  15. package/dist/Components/Documents/TabButtons.js +2 -2
  16. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  17. package/dist/Components/Forms/ApiForm.js +1 -1
  18. package/dist/Components/Forms/Crud.js +177 -58
  19. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  20. package/dist/Components/Forms/CrudParts.js +37 -5
  21. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  22. package/dist/Components/Forms/ExcelImport.js +189 -60
  23. package/dist/Components/Forms/Form.js +5 -13
  24. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  25. package/dist/Components/Forms/SubmitForm.js +23 -12
  26. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  27. package/dist/Components/Inputs/DropdownParts.js +4 -6
  28. package/dist/Components/Inputs/NumberBox.js +24 -2
  29. package/dist/Components/Inputs/RowsInput.js +2 -2
  30. package/dist/Components/Inputs/Slider.js +4 -1
  31. package/dist/Components/Inputs/TabularForm.js +42 -10
  32. package/dist/Components/Inputs/TimePanel.js +4 -3
  33. package/dist/Components/Layouts/Popup.js +1 -4
  34. package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
  35. package/dist/Components/Layouts/ThemePicker.js +77 -6
  36. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  37. package/dist/Components/Layouts/ThemeScript.js +4 -2
  38. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  39. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  40. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  41. package/dist/Components/Viewers/Avatar.js +21 -0
  42. package/dist/Components/Viewers/DataTable.js +192 -44
  43. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  44. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  45. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  46. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  47. package/dist/Components/Viewers/DataTableParts.d.ts +2 -2
  48. package/dist/Components/Viewers/DataTableParts.js +3 -3
  49. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  50. package/dist/Helpers/AnimationHelpers.js +20 -2
  51. package/dist/Helpers/CrudBackend.d.ts +7 -4
  52. package/dist/Helpers/CrudBackend.js +4 -2
  53. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  54. package/dist/Helpers/ExcelBackend.js +8 -2
  55. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  56. package/dist/Helpers/ExcelHelpers.js +55 -13
  57. package/dist/Helpers/FormStore.d.ts +9 -5
  58. package/dist/Helpers/FormStore.js +25 -5
  59. package/dist/Helpers/MessageBuilder.js +1 -0
  60. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  61. package/dist/Helpers/NumberHelpers.js +41 -3
  62. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  63. package/dist/Helpers/PopoverHelpers.js +52 -17
  64. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  65. package/dist/Helpers/PopoverHooks.js +62 -0
  66. package/dist/Helpers/RowsStore.d.ts +7 -2
  67. package/dist/Helpers/RowsStore.js +15 -3
  68. package/dist/Helpers/TableExport.js +12 -12
  69. package/dist/Helpers/TableFilters.d.ts +25 -0
  70. package/dist/Helpers/TableFilters.js +162 -0
  71. package/dist/Helpers/TableHelpers.d.ts +6 -3
  72. package/dist/Helpers/TableHelpers.js +8 -4
  73. package/dist/Helpers/TableStore.d.ts +11 -3
  74. package/dist/Helpers/TableStore.js +16 -5
  75. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  76. package/dist/Helpers/ThemeHelpers.js +46 -9
  77. package/dist/Helpers/UserPreferences.d.ts +14 -0
  78. package/dist/Helpers/UserPreferences.js +99 -0
  79. package/dist/Interfaces/CrudInterfaces.d.ts +20 -1
  80. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  81. package/dist/Interfaces/FormInterfaces.d.ts +33 -5
  82. package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
  83. package/dist/Interfaces/PreferenceInterfaces.js +28 -0
  84. package/dist/Interfaces/TableInterfaces.d.ts +59 -12
  85. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  86. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  87. package/dist/Services/ExcelApi.d.ts +3 -1
  88. package/dist/Services/ExcelApi.js +1 -1
  89. package/dist/Services/ThemeService.d.ts +18 -2
  90. package/dist/Services/ThemeService.js +80 -16
  91. package/dist/Shared.Index.d.ts +2 -0
  92. package/dist/Shared.Index.js +2 -0
  93. package/package.json +85 -85
  94. package/src/Styles/Nexus.Avatar.css +79 -0
  95. package/src/Styles/Nexus.Base.css +117 -70
  96. package/src/Styles/Nexus.Button.css +180 -153
  97. package/src/Styles/Nexus.Calendar.css +273 -275
  98. package/src/Styles/Nexus.Chat.css +1443 -1489
  99. package/src/Styles/Nexus.Choice.css +343 -345
  100. package/src/Styles/Nexus.Crud.css +168 -161
  101. package/src/Styles/Nexus.DateTime.css +81 -81
  102. package/src/Styles/Nexus.Dropdown.css +725 -774
  103. package/src/Styles/Nexus.Editor.css +295 -297
  104. package/src/Styles/Nexus.Excel.css +389 -370
  105. package/src/Styles/Nexus.Form.css +279 -321
  106. package/src/Styles/Nexus.Index.css +37 -36
  107. package/src/Styles/Nexus.Input.css +602 -637
  108. package/src/Styles/Nexus.Markdown.css +237 -265
  109. package/src/Styles/Nexus.Menu.css +237 -259
  110. package/src/Styles/Nexus.Popup.css +724 -731
  111. package/src/Styles/Nexus.RichText.css +2 -32
  112. package/src/Styles/Nexus.Rows.css +637 -596
  113. package/src/Styles/Nexus.Slider.css +276 -278
  114. package/src/Styles/Nexus.Tab.Buttons.css +432 -419
  115. package/src/Styles/Nexus.Table.css +1386 -1296
  116. package/src/Styles/Nexus.Theme.Picker.css +286 -232
  117. package/src/Styles/Nexus.Themes.css +194 -194
  118. package/src/Styles/Nexus.Time.css +144 -147
  119. package/src/Styles/Nexus.Toast.css +263 -265
  120. package/src/Styles/Nexus.Tokens.css +89 -72
  121. package/src/Styles/Nexus.Tree.css +431 -435
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconFilterOff, InlineIcon } from "nexus-icons";
4
+ import { useEffect, useId, useRef } from "react";
5
+ import { usePopoverPanel } from "../../Helpers/PopoverHooks.js";
6
+ import { activeFilters } from "../../Helpers/TableFilters.js";
7
+ import { Button } from "../Documents/Button.js";
8
+ import { Form, useForm } from "../Forms/Form.js";
9
+ import { formatCount } from "./DataTableParts.js";
10
+ /*
11
+ * The Filters panel: the table's filter inputs as one small form under the options button (⋮), applied as they change.
12
+ * A choice applies at once; a value that can come a key or a step at a time (text, a number, a slider, a date) waits
13
+ * the table's search delay, or Enter, or the panel closing. Esc closes the panel, an open list or calendar first, and
14
+ * never clears a filter on its way out.
15
+ */
16
+ /** Inputs typed into or dragged: their value waits until the person stops. A date box reads a date from part of one. */
17
+ const TYPED = new Set(["text", "email", "search", "tel", "url", "textarea", "number", "slider", "range-slider", "date", "date-range", "time", "date-time"]);
18
+ /** The Filters panel, opened through its `open()` handle: a `Form` of the filter inputs, the count, and Clear all. */
19
+ export function TableFiltersPanel({ inputs, value, delay, matches, total, onChange, anchor, ref }) {
20
+ const titleId = useId();
21
+ const form = useForm();
22
+ // The form is drawn on the first open and kept: what was typed, and the labels a server list loaded, stay.
23
+ const { panelRef, shown, onToggle } = usePopoverPanel(anchor, ref, () => form.focus());
24
+ const pending = useRef(undefined);
25
+ const count = value ? Object.keys(value).length : 0;
26
+ const apply = () => {
27
+ clearTimeout(pending.current);
28
+ pending.current = undefined;
29
+ onChange(form.getValue());
30
+ };
31
+ const wait = () => {
32
+ clearTimeout(pending.current);
33
+ pending.current = setTimeout(apply, delay);
34
+ };
35
+ const flush = () => pending.current !== undefined && apply();
36
+ // Filters changed from outside reach the form, unless a typed value is still on its way.
37
+ useEffect(() => {
38
+ if (form.started && pending.current === undefined && JSON.stringify(activeFilters(inputs, form.getValue())) !== JSON.stringify(value))
39
+ form.load(value ?? {});
40
+ }, [form, inputs, value]);
41
+ useEffect(() => () => clearTimeout(pending.current), []);
42
+ return (_jsxs("div", { ref: panelRef, popover: "auto", role: "dialog", tabIndex: -1, "aria-labelledby": titleId, className: "nx-popover nx-table-panel nx-table-filters", onToggle: onToggle, onBeforeToggle: event => {
43
+ // Before the click that closes it lands: a clear that click makes then comes after what was typed.
44
+ if (event.target === event.currentTarget && event.newState === "closed")
45
+ flush();
46
+ }, onKeyDownCapture: event => {
47
+ // Kept from the inputs, whose Esc clears a value first: with no list or calendar open, Esc closes the panel.
48
+ if (event.key === "Escape" && !event.currentTarget.querySelector(":popover-open"))
49
+ event.stopPropagation();
50
+ }, onKeyDown: event => {
51
+ if (event.key === "Enter")
52
+ flush();
53
+ }, onInput: () => {
54
+ // A key that changes nothing the box reads ("2025-01-01 -") is typing too: a waiting value waits on.
55
+ if (pending.current !== undefined)
56
+ wait();
57
+ }, children: [_jsxs("div", { className: "nx-table-panel__head", children: [_jsx("span", { className: "nx-table-panel__title", id: titleId, children: "Filters" }), _jsx("span", { className: "nx-table-panel__count", children: count ? `${count} on` : "None" })] }), _jsx("div", { className: "nx-popover__scroll", children: shown && (_jsx(Form, { form: form, inputs: inputs, size: "sm", defaultValue: value, className: "nx-table-filters__form", onValueChange: (_, name) => {
58
+ if (name === null)
59
+ return;
60
+ if (delay > 0 && TYPED.has(inputs.find(params => params.name === name)?.type ?? ""))
61
+ wait();
62
+ else
63
+ apply();
64
+ } })) }), _jsxs("div", { className: "nx-table-panel__foot", children: [_jsxs("span", { className: "nx-table-filters__rows", "aria-live": "polite", children: [formatCount(matches), " of ", formatCount(total), " rows"] }), _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconFilterOff }), disabled: !count, onClick: () => {
65
+ form.load({});
66
+ apply();
67
+ }, children: "Clear all" })] })] }));
68
+ }
@@ -127,8 +127,8 @@ export declare function TablePageSize({ pageSize, pageSizes, compact, onChange }
127
127
  onChange: (size: number) => void;
128
128
  }): import("react").JSX.Element | null;
129
129
  /**
130
- * Under the table: which rows show and how many the search left ("Showing 41–50 of 234 · filtered from 1,000"), the page
131
- * of how many, and the pager.
130
+ * Under the table: which rows show and how many the search and the filters left ("Showing 41–50 of 234 · filtered
131
+ * from 1,000"), the page of how many, and the pager.
132
132
  */
133
133
  export declare function TableFooter<TRow extends TableRow>({ state, disabled, onPage }: {
134
134
  state: TableState<TRow>;
@@ -143,8 +143,8 @@ export function TablePageSize({ pageSize, pageSizes, compact = false, onChange }
143
143
  return (_jsx(Menu, { items: items, kind: "radio", size: "sm", align: compact ? "start" : "end", "aria-label": "Rows per page", onSelect: value => onChange(Number(value)), children: _jsx(Button, { variant: "ghost", size: "sm", iconEnd: _jsx(InlineIcon, { icon: iconChevronDown }), className: cx("nx-table__page-size", compact && "nx-table__page-size--field"), "aria-label": `Rows per page: ${pageSize}`, title: "Rows per page", children: compact ? formatCount(pageSize) : `${formatCount(pageSize)} / page` }) }));
144
144
  }
145
145
  /**
146
- * Under the table: which rows show and how many the search left ("Showing 41–50 of 234 · filtered from 1,000"), the page
147
- * of how many, and the pager.
146
+ * Under the table: which rows show and how many the search and the filters left ("Showing 41–50 of 234 · filtered
147
+ * from 1,000"), the page of how many, and the pager.
148
148
  */
149
149
  export function TableFooter({ state, disabled, onPage }) {
150
150
  const { status, rows, offset, matches, total, query } = state;
@@ -153,5 +153,5 @@ export function TableFooter({ state, disabled, onPage }) {
153
153
  const first = rows.length ? offset + 1 : 0;
154
154
  const last = offset + rows.length;
155
155
  const pages = pageCount(matches, query.pageSize);
156
- return (_jsxs("div", { className: "nx-table__footer", children: [_jsxs("p", { className: "nx-table__range", "aria-live": "polite", children: [rows.length ? (_jsxs(_Fragment, { children: ["Showing ", _jsx("b", { children: `${formatCount(first)}–${formatCount(last)}` }), " of ", _jsx("b", { children: formatCount(matches) })] })) : (_jsx(_Fragment, { children: "No rows on this page" })), query.search && matches !== total && _jsxs("span", { className: "nx-table__range-all", children: [" \u00B7 filtered from ", formatCount(total)] })] }), _jsxs("div", { className: "nx-table__paging", children: [_jsxs("span", { className: "nx-table__page-info", children: ["Page ", formatCount(query.page), " of ", formatCount(pages)] }), _jsx(TablePager, { page: query.page, matches: matches, pageSize: query.pageSize, disabled: disabled, onPage: onPage })] })] }));
156
+ return (_jsxs("div", { className: "nx-table__footer", children: [_jsxs("p", { className: "nx-table__range", "aria-live": "polite", children: [rows.length ? (_jsxs(_Fragment, { children: ["Showing ", _jsx("b", { children: `${formatCount(first)}–${formatCount(last)}` }), " of ", _jsx("b", { children: formatCount(matches) })] })) : (_jsx(_Fragment, { children: "No rows on this page" })), (query.search || query.filters) && matches !== total && _jsxs("span", { className: "nx-table__range-all", children: [" \u00B7 filtered from ", formatCount(total)] })] }), _jsxs("div", { className: "nx-table__paging", children: [_jsxs("span", { className: "nx-table__page-info", children: ["Page ", formatCount(query.page), " of ", formatCount(pages)] }), _jsx(TablePager, { page: query.page, matches: matches, pageSize: query.pageSize, disabled: disabled, onPage: onPage })] })] }));
157
157
  }
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Whether things may move, as the stylesheets read it: `data-nx-motion` on `<html>`, which `ThemeScript` works out
3
+ * from the visitor's Animations choice and the system setting. Motion started from script (a flight, a smooth scroll)
4
+ * asks this, never `prefers-reduced-motion`, so "on" animates on a machine that reports reduced motion.
5
+ */
6
+ export declare function motionAllowed(): boolean;
7
+ /**
8
+ * Whether a scroll may be smooth: the visitor allows motion, and so does the system, because a browser whose system
9
+ * turned animations off scrolls at once whatever it is asked, and a smooth scroll that is skipped must not be waited on.
10
+ */
11
+ export declare function smoothScrollAllowed(): boolean;
1
12
  /**
2
13
  * Calls `done` once the element's CSS animation `name` has finished, or at once when the element runs no animation of
3
14
  * that name. Call it after the class that starts the animation is on the element, e.g. in a layout effect.
@@ -1,8 +1,26 @@
1
1
  /*
2
2
  * Exit animations. A closing element gets a class that starts a CSS animation, and the next step (closing a dialog,
3
- * removing a toast) waits for it. When no animation runs, the step must not wait: with reduced motion (which Windows
4
- * reports when its animations are turned off) or when a theme sets `animation: none`.
3
+ * removing a toast) waits for it. When no animation runs, the step must not wait: with animations off (the visitor's
4
+ * choice, or the system's under "system" - Windows reports reduced motion when its animations are turned off) or when
5
+ * a theme sets `animation: none`.
5
6
  */
7
+ import { MOTION_ATTRIBUTE } from "../Interfaces/ThemeInterfaces.js";
8
+ import { REDUCED_MOTION_QUERY } from "./ThemeHelpers.js";
9
+ /**
10
+ * Whether things may move, as the stylesheets read it: `data-nx-motion` on `<html>`, which `ThemeScript` works out
11
+ * from the visitor's Animations choice and the system setting. Motion started from script (a flight, a smooth scroll)
12
+ * asks this, never `prefers-reduced-motion`, so "on" animates on a machine that reports reduced motion.
13
+ */
14
+ export function motionAllowed() {
15
+ return typeof document === "undefined" || document.documentElement.getAttribute(MOTION_ATTRIBUTE) !== "reduce";
16
+ }
17
+ /**
18
+ * Whether a scroll may be smooth: the visitor allows motion, and so does the system, because a browser whose system
19
+ * turned animations off scrolls at once whatever it is asked, and a smooth scroll that is skipped must not be waited on.
20
+ */
21
+ export function smoothScrollAllowed() {
22
+ return motionAllowed() && !(typeof window !== "undefined" && window.matchMedia?.(REDUCED_MOTION_QUERY).matches);
23
+ }
6
24
  /**
7
25
  * Calls `done` once the element's CSS animation `name` has finished, or at once when the element runs no animation of
8
26
  * that name. Call it after the class that starts the animation is on the element, e.g. in a layout effect.
@@ -2,8 +2,11 @@ import type { CrudMassActionName, CrudMode } from "../Interfaces/CrudInterfaces.
2
2
  import type { TableKey, TablePage, TableQuery, TableRow } from "../Interfaces/TableInterfaces.ts";
3
3
  type Row = Record<string, unknown>;
4
4
  export declare abstract class CrudBackend {
5
- /** What a server table sends for one page, from the query. `sortKey` is the sorted column's server name. */
6
- abstract pageRequest(query: TableQuery, sortKey: string | undefined): unknown;
5
+ /**
6
+ * What a server table sends for one page, from the query. `sortKey` is the sorted column's server name. `filters` are
7
+ * the active filters already in the server's names (`filterRequest`); `query.filters` keeps the raw values.
8
+ */
9
+ abstract pageRequest(query: TableQuery, sortKey: string | undefined, filters?: Readonly<Record<string, unknown>>): unknown;
7
10
  /** Reads the page a pagination endpoint answered. */
8
11
  abstract readPage<TRow extends TableRow>(result: unknown): TablePage<TRow>;
9
12
  /** Reads a whole list: an array, or the rows of a page. */
@@ -60,12 +63,12 @@ export declare const NEXUS_TRASH_STATUS = 40;
60
63
  /** The Nexus record status of a deleted record (`ERecordStatus.PermanentDeleted`), the bottom of the 60+ delete band. */
61
64
  export declare const NEXUS_DELETED_STATUS = 60;
62
65
  /**
63
- * The Nexus backend: pages as `{ searchKeyword, skipCount, maxCount, sorting, sortType }` answered with
66
+ * The Nexus backend: pages as `{ ...filters, searchKeyword, skipCount, maxCount, sorting, sortType }` answered with
64
67
  * `{ results, totalCount, filterCount }`, newest first unless a column is sorted; mass actions send the ids
65
68
  * (`{ description, accountIds }` for descriptions); a flagged record has `recordStatus` 25, a pinned one `isPinned`.
66
69
  */
67
70
  export declare class NexusCrudBackend extends CrudBackend {
68
- pageRequest(query: TableQuery, sortKey: string | undefined): unknown;
71
+ pageRequest(query: TableQuery, sortKey: string | undefined, filters?: Readonly<Record<string, unknown>>): unknown;
69
72
  readPage<TRow extends TableRow>(result: unknown): TablePage<TRow>;
70
73
  massRequest(action: CrudMassActionName, keys: TableKey[], values?: Row): unknown;
71
74
  isFlagged(row: TableRow): boolean;
@@ -86,13 +86,15 @@ export const NEXUS_DELETED_STATUS = 60;
86
86
  /** Just past the flag band: a flagged record is 25–29, so 30 and up is live but not flagged. */
87
87
  const NEXUS_FLAG_BAND_END = 30;
88
88
  /**
89
- * The Nexus backend: pages as `{ searchKeyword, skipCount, maxCount, sorting, sortType }` answered with
89
+ * The Nexus backend: pages as `{ ...filters, searchKeyword, skipCount, maxCount, sorting, sortType }` answered with
90
90
  * `{ results, totalCount, filterCount }`, newest first unless a column is sorted; mass actions send the ids
91
91
  * (`{ description, accountIds }` for descriptions); a flagged record has `recordStatus` 25, a pinned one `isPinned`.
92
92
  */
93
93
  export class NexusCrudBackend extends CrudBackend {
94
- pageRequest(query, sortKey) {
94
+ pageRequest(query, sortKey, filters) {
95
95
  return {
96
+ // Flat and first, so a filter never overwrites a paging field.
97
+ ...filters,
96
98
  searchKeyword: query.search,
97
99
  skipCount: (query.page - 1) * query.pageSize,
98
100
  maxCount: query.pageSize,
@@ -43,8 +43,8 @@ export interface ExcelDownloadHeaders {
43
43
  export declare abstract class ExcelBackend {
44
44
  /** The form field an upload is sent under. */
45
45
  abstract get uploadField(): string;
46
- /** What an import sends: the rows the verify answered with, as the person left them. */
47
- abstract importRequest(rows: readonly ExcelSheetRow[]): unknown;
46
+ /** What an import sends: the rows the verify answered with, as the person left them, and the status they start in. */
47
+ abstract importRequest(rows: readonly ExcelSheetRow[], recordStatus?: number): unknown;
48
48
  /** What an export sends: the search, the sort and the filters the person has on screen. */
49
49
  abstract exportRequest(query: ExcelExportQuery): unknown;
50
50
  /** Reads what the settings endpoint answered. */
@@ -67,7 +67,7 @@ export declare abstract class ExcelBackend {
67
67
  */
68
68
  export declare class NexusExcelBackend extends ExcelBackend {
69
69
  get uploadField(): string;
70
- importRequest(rows: readonly ExcelSheetRow[]): unknown;
70
+ importRequest(rows: readonly ExcelSheetRow[], recordStatus?: number): unknown;
71
71
  /**
72
72
  * The same request a Nexus listing takes, minus `skipCount` and `maxCount`: an export writes every row the search
73
73
  * leaves, up to the backend's own limit, not the page the person happens to be on. With nothing sorted it follows
@@ -53,8 +53,8 @@ export class NexusExcelBackend extends ExcelBackend {
53
53
  get uploadField() {
54
54
  return "file";
55
55
  }
56
- importRequest(rows) {
57
- return { rows };
56
+ importRequest(rows, recordStatus) {
57
+ return recordStatus === undefined ? { rows } : { rows, recordStatus };
58
58
  }
59
59
  /**
60
60
  * The same request a Nexus listing takes, minus `skipCount` and `maxCount`: an export writes every row the search
@@ -79,6 +79,10 @@ export class NexusExcelBackend extends ExcelBackend {
79
79
  maxImportRows: count(answer.maxImportRows, DEFAULT_MAX_IMPORT_ROWS),
80
80
  columns: readColumns(answer.columns),
81
81
  inputs: Array.isArray(answer.inputs) ? answer.inputs : [],
82
+ recordStatuses: Array.isArray(answer.recordStatuses)
83
+ ? answer.recordStatuses.filter(isRecord).flatMap(status => (typeof status.value === "number" && typeof status.label === "string" ? [{ value: status.value, label: status.label }] : []))
84
+ : [],
85
+ defaultRecordStatus: typeof answer.defaultRecordStatus === "number" ? answer.defaultRecordStatus : undefined,
82
86
  };
83
87
  }
84
88
  readVerify(result) {
@@ -233,6 +237,8 @@ export function readExcelResponse(body, status) {
233
237
  messageCode: text(first.messageCode) ?? undefined,
234
238
  errorCode: first.errorCode === undefined ? undefined : count(first.errorCode),
235
239
  ...(Object.keys(errors).length ? { errors } : {}),
240
+ // An import that wrote nothing still says, row by row, what it refused and why (XLS_X044): keep that report.
241
+ ...(isRecord(body.result) ? { result: body.result } : {}),
236
242
  };
237
243
  }
238
244
  }
@@ -8,11 +8,14 @@ export declare const EXCEL_ROW_NUMBER = "__excelRow";
8
8
  export declare const EXCEL_ROW_KEYS = "__excelKeys";
9
9
  /** The field of an editor row that holds what the verify answered for it, so an edit can be told from an untouched row. */
10
10
  export declare const EXCEL_ROW_SENT = "__excelSent";
11
- /** One row of a review editor: a value per column, plus the three fields the editor carries for itself. */
11
+ /**
12
+ * One row of a review editor: a value per column, plus the three fields the editor carries for itself. A row added in
13
+ * the browser - by Add, Insert or Duplicate, or every row of a bulk insert - has none of the three, or only some.
14
+ */
12
15
  export interface ExcelEditorRow {
13
- [EXCEL_ROW_NUMBER]: number;
14
- [EXCEL_ROW_KEYS]: Record<string, string | null>;
15
- [EXCEL_ROW_SENT]: Record<string, string | null>;
16
+ [EXCEL_ROW_NUMBER]?: number;
17
+ [EXCEL_ROW_KEYS]?: Record<string, string | null>;
18
+ [EXCEL_ROW_SENT]?: Record<string, string | null>;
16
19
  [field: string]: unknown;
17
20
  }
18
21
  declare const DEFAULT_TEXT: {
@@ -25,7 +28,15 @@ declare const DEFAULT_TEXT: {
25
28
  readonly verifyFailed: "The file could not be read.";
26
29
  readonly newRows: "New rows";
27
30
  readonly duplicates: "Already there";
28
- readonly duplicateHint: "Nothing here is updated. Edit a row to add it as a new record instead.";
31
+ readonly duplicateHint: "Already records: these are skipped, and nothing here is updated.";
32
+ readonly failedHead: "Not written";
33
+ readonly skippedHead: "Skipped: already there";
34
+ readonly importAgain: "To import another file, start over.";
35
+ readonly addAgain: "To add more, start over.";
36
+ readonly bulkUnavailable: "The sheet's columns could not be read, so there is nothing to type the rows into.";
37
+ readonly discardTitle: "Discard what you typed?";
38
+ readonly discardLabel: "Discard";
39
+ readonly keepEditing: "Keep editing";
29
40
  readonly toFix: "What needs fixing";
30
41
  readonly whyDuplicate: "Why these are already there";
31
42
  readonly referenceLocked: "Picked in the spreadsheet: this list belongs to the server.";
@@ -66,18 +77,35 @@ export declare function isExcelColumnEditable(column: ExcelColumnInfo): boolean;
66
77
  * A reference cell shows the **words** the person picked, because its key is not something anybody reads or types.
67
78
  */
68
79
  export declare function excelEditorRows(rows: readonly ExcelReviewRow[], columns: readonly ExcelColumnInfo[]): ExcelEditorRow[];
80
+ /** The sheet row a row came from, or 0 for a row added in the browser. */
81
+ export declare function excelRowNumber(row: ExcelEditorRow): number;
69
82
  /**
70
83
  * The editors' rows as rows to send: every value as text, every reference as the key it came with, and the sheet's own
71
84
  * row number kept so a problem can still name where it is.
72
85
  *
73
86
  * A cell the person did not touch goes back **exactly** as it came, which is what makes the round trip faithful: the
74
87
  * backend reads its own text again, not a number an input rewrote on the way through.
88
+ *
89
+ * 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
90
+ * number, and a problem still names the row it is about. `after` is that last row - the file's, rows already there and
91
+ * rows taken out of the review included, which are not among the rows sent. A bulk insert, with no sheet at all,
92
+ * numbers its rows 1, 2, 3.
75
93
  */
76
- export declare function excelSheetRows(rows: readonly ExcelEditorRow[], columns: readonly ExcelColumnInfo[]): ExcelSheetRow[];
94
+ export declare function excelSheetRows(rows: readonly ExcelEditorRow[], columns: readonly ExcelColumnInfo[], after?: number): ExcelSheetRow[];
77
95
  /** Whether the person changed anything in a row since the verify answered it. */
78
96
  export declare function isExcelRowEdited(row: ExcelEditorRow, columns: readonly ExcelColumnInfo[]): boolean;
79
- /** Where a problem is, as the person reads it: the cell, a middle dot, and the column's heading ("B7 - City name"). */
80
- export declare function excelProblemPlace(problem: ExcelCellProblem): string;
97
+ /** Whether a row has anything in a cell the person types in. A row added and left empty has not, and is not sent. */
98
+ export declare function hasExcelValues(row: ExcelEditorRow, columns: readonly ExcelColumnInfo[]): boolean;
99
+ /**
100
+ * Whether a row holds something only this screen has, which closing it would lose: a sheet's row the person edited, or
101
+ * a row added here with anything in it.
102
+ */
103
+ export declare function isExcelRowTyped(row: ExcelEditorRow, columns: readonly ExcelColumnInfo[]): boolean;
104
+ /**
105
+ * Where a problem is, as the person reads it: the cell, a middle dot, and the column's heading ("B7 - City name"). Rows
106
+ * typed in the browser have no sheet, so `byRow` names the row instead ("Row 3 - City name").
107
+ */
108
+ export declare function excelProblemPlace(problem: ExcelCellProblem, byRow?: boolean): string;
81
109
  /** Every problem of these rows, in the order the rows read. */
82
110
  export declare function excelProblems(rows: readonly ExcelReviewRow[]): ExcelCellProblem[];
83
111
  /** The rows of one kind. */
@@ -1,4 +1,6 @@
1
1
  import { ExcelDataType, ExcelRowKind } from "../Interfaces/ExcelInterfaces.js";
2
+ import { toIsoDate } from "./DateHelpers.js";
3
+ import { toIsoTime } from "./TimeHelpers.js";
2
4
  /*
3
5
  * Turning a sheet's columns into inputs, a verify's rows into editable rows and back, and handing the person a file.
4
6
  * Nothing here calls anything: the calls are in Services/ExcelApi.ts.
@@ -28,7 +30,15 @@ const DEFAULT_TEXT = {
28
30
  verifyFailed: "The file could not be read.",
29
31
  newRows: "New rows",
30
32
  duplicates: "Already there",
31
- duplicateHint: "Nothing here is updated. Edit a row to add it as a new record instead.",
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",
32
42
  toFix: "What needs fixing",
33
43
  whyDuplicate: "Why these are already there",
34
44
  referenceLocked: "Picked in the spreadsheet: this list belongs to the server.",
@@ -159,12 +169,15 @@ export function excelEditorRows(rows, columns) {
159
169
  /** Which types are drawn by a number box, whose empty value is `null`. */
160
170
  const NUMBER_TYPES = new Set([ExcelDataType.WholeNumber, ExcelDataType.Decimal, ExcelDataType.Amount]);
161
171
  /**
162
- * A cell's text as the input that draws it wants it: a number as a number, a choice unset rather than empty, and
163
- * everything else as it reads.
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.
164
174
  *
165
175
  * The three empties are not interchangeable. A number box is empty at `null`; a **dropdown** given `""` treats it as a
166
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
167
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.
168
181
  */
169
182
  function toInputValue(column, value) {
170
183
  if (value === null || value === "")
@@ -184,6 +197,10 @@ function toInputValue(column, value) {
184
197
  return "No";
185
198
  return value;
186
199
  }
200
+ case ExcelDataType.Date:
201
+ return toIsoDate(value) ?? value;
202
+ case ExcelDataType.Time:
203
+ return toIsoTime(value) ?? value;
187
204
  default:
188
205
  return value;
189
206
  }
@@ -210,17 +227,28 @@ function isSameCell(column, current, sent) {
210
227
  return true;
211
228
  return toInputValue(column, asSheet) === toInputValue(column, sent ?? null);
212
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
+ }
213
235
  /**
214
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
215
237
  * row number kept so a problem can still name where it is.
216
238
  *
217
239
  * A cell the person did not touch goes back **exactly** as it came, which is what makes the round trip faithful: the
218
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.
219
246
  */
220
- export function excelSheetRows(rows, columns) {
221
- return rows.map((row, index) => {
222
- const keys = (row[EXCEL_ROW_KEYS] ?? {});
223
- const sent = (row[EXCEL_ROW_SENT] ?? {});
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] ?? {};
224
252
  const values = {};
225
253
  const labels = {};
226
254
  for (const column of columns) {
@@ -231,13 +259,12 @@ export function excelSheetRows(rows, columns) {
231
259
  }
232
260
  values[column.field] = isSameCell(column, row[column.field], sent[column.field]) ? (sent[column.field] ?? null) : toSheetValue(row[column.field]);
233
261
  }
234
- const number = Number(row[EXCEL_ROW_NUMBER]);
235
- return { number: Number.isFinite(number) && number > 0 ? number : index + 1, values, labels };
262
+ return { number: excelRowNumber(row) || ++next, values, labels };
236
263
  });
237
264
  }
238
265
  /** Whether the person changed anything in a row since the verify answered it. */
239
266
  export function isExcelRowEdited(row, columns) {
240
- const sent = (row[EXCEL_ROW_SENT] ?? {});
267
+ const sent = row[EXCEL_ROW_SENT] ?? {};
241
268
  for (const column of columns) {
242
269
  if (!isExcelColumnEditable(column))
243
270
  continue;
@@ -246,10 +273,25 @@ export function isExcelRowEdited(row, columns) {
246
273
  }
247
274
  return false;
248
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
+ }
249
287
  /* What is wrong, in the person's words */
250
- /** Where a problem is, as the person reads it: the cell, a middle dot, and the column's heading ("B7 - City name"). */
251
- export function excelProblemPlace(problem) {
252
- return problem.cell ? `${problem.cell} ${String.fromCharCode(183)} ${problem.header}` : problem.header;
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;
253
295
  }
254
296
  /** Every problem of these rows, in the order the rows read. */
255
297
  export function excelProblems(rows) {
@@ -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;
@@ -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 */
@@ -43,6 +43,7 @@ const DEFAULT_TEXTS = {
43
43
  confirmDetails: {
44
44
  delete: "This cannot be undone.",
45
45
  trash: "It can be restored from the trash.",
46
+ recover: "It goes back to the active list.",
46
47
  },
47
48
  reasons: {
48
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".