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
@@ -1,15 +1,16 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { iconEye, iconFlag, iconFlagOff, iconList, iconLock, iconNotes, iconPencil, iconPin, iconPinnedOff, iconPlus, iconRestore, iconTrash, iconTrashX, InlineIcon, } from "nexus-icons";
3
+ import { iconArrowBackUp, iconEye, iconFlag, iconFlagOff, iconInfoCircle, iconList, iconLoader2, iconLock, iconNotes, iconPencil, iconPin, iconPinnedOff, iconPlus, iconTrash, iconTrashX, InlineIcon, } from "nexus-icons";
4
4
  import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { abortError } from "../../Helpers/ApiFormHelpers.js";
6
- import { unwrapApiResult } from "../../Helpers/ApiResponses.js";
6
+ import { isAbortError, unwrapApiResult } from "../../Helpers/ApiResponses.js";
7
7
  import { getCrudBackend } from "../../Helpers/CrudBackend.js";
8
8
  import { actionAllowed, availableModes, CRUD_CACHE_LIFETIME, CRUD_MODES, CRUD_ROW_ACTIONS, CrudCache, crudCacheMode, crudListEndpoint, crudMassActions, crudMessageAction, crudRowActions, crudRowEndpoint, getCrudSettings, leavesList, massActionAllowed, patchCachedList, pinRoom, } from "../../Helpers/CrudHelpers.js";
9
9
  import { cx } from "../../Helpers/ClassHelpers.js";
10
10
  import { FormStore } from "../../Helpers/FormStore.js";
11
11
  import { pluralize } from "../../Helpers/MessageBuilder.js";
12
12
  import { getPermissions, permissionsVersion, subscribePermissions } from "../../Helpers/Permissions.js";
13
+ import { filterRequest } from "../../Helpers/TableFilters.js";
13
14
  import { columnValue, formatCellText, readPath } from "../../Helpers/TableHelpers.js";
14
15
  import { api as browserApi } from "../../Services/BrowserApi.js";
15
16
  import { Button } from "../Documents/Button.js";
@@ -29,13 +30,14 @@ import { confirmDiscard, FormButtons } from "./SubmitForm.js";
29
30
  */
30
31
  const ALL_ACTIONS = ["create", "view", "edit", "flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"];
31
32
  const MODE_INFO = {
32
- normal: { label: "All", icon: iconList, empty: plural => `No ${plural} yet.` },
33
+ // "Active", not "All": the list leaves out what is in the trash and what is deleted, and "All" said otherwise.
34
+ normal: { label: "Active", icon: iconList, empty: plural => `No ${plural} yet.` },
33
35
  flag: { label: "Flagged", icon: iconFlag, empty: plural => `No flagged ${plural}.` },
34
36
  trash: { label: "Trash", icon: iconTrash, empty: () => "The trash is empty." },
35
37
  deleted: { label: "Deleted", icon: iconTrashX, empty: plural => `No deleted ${plural}.` },
36
38
  };
37
- /** Actions that take a record out of reach are asked about first; flags, pins and recover are undone by a click. */
38
- const ASKED = new Set(["trash", "delete", "trashFromDelete"]);
39
+ /** Actions that move a record between the lists are asked about first; flags and pins are undone by a click. */
40
+ const ASKED = new Set(["trash", "recover", "delete", "trashFromDelete"]);
39
41
  /** Actions that take a record out of the active lists, so it is no longer among the pinned records either. */
40
42
  const LEFT_ACTIVE = new Set(["trash", "delete", "trashFromDelete"]);
41
43
  /** The words of an action that flags some records and unflags others (a mass toggle of a mixed selection). */
@@ -65,6 +67,65 @@ function CrudFormBody({ aside, details, children }) {
65
67
  function addsToRow(row, full) {
66
68
  return Object.entries(full).some(([name, value]) => JSON.stringify(value) !== JSON.stringify(row[name]));
67
69
  }
70
+ /**
71
+ * A details record that lands sooner than this says nothing: the demo answers in about 0.3s and most backends sooner,
72
+ * and a word shown for a frame or two is the flicker this avoids. Longer than the popup's 160ms fade, so the word
73
+ * never lands on a half-faded popup.
74
+ */
75
+ const DETAILS_STATUS_DELAY = 400;
76
+ /** Once "Loading…" shows, it stays at least this long, so a record that lands just after the delay does not blink it. */
77
+ const DETAILS_STATUS_HOLD = 300;
78
+ /** One details popup's load state, shared by the details (which load) and the footer (which says so), as a form popup shares its FormStore with FormButtons. */
79
+ function detailsLoad(initial) {
80
+ let state = initial;
81
+ const listeners = new Set();
82
+ return {
83
+ get: () => state,
84
+ set(next) {
85
+ if (next === state)
86
+ return;
87
+ state = next;
88
+ for (const listener of listeners)
89
+ listener();
90
+ },
91
+ subscribe(listener) {
92
+ listeners.add(listener);
93
+ return () => {
94
+ listeners.delete(listener);
95
+ };
96
+ },
97
+ };
98
+ }
99
+ /**
100
+ * A details popup's footer: a form's bar, its status line on the left and Edit and Close on the right. The line is
101
+ * always there, so what it says never moves anything: "Loading…" only for a slow record, "Not all fields loaded." for
102
+ * one that failed.
103
+ */
104
+ function DetailsFooter({ load, children }) {
105
+ const state = useSyncExternalStore(load.subscribe, load.get, load.get);
106
+ const [said, setSaid] = useState(null);
107
+ const since = useRef(0);
108
+ useEffect(() => {
109
+ if (state === "loading") {
110
+ if (said === "loading")
111
+ return;
112
+ const timer = window.setTimeout(() => {
113
+ since.current = performance.now();
114
+ setSaid("loading");
115
+ }, DETAILS_STATUS_DELAY);
116
+ return () => window.clearTimeout(timer);
117
+ }
118
+ const next = state === "failed" ? "failed" : null;
119
+ const left = said === "loading" ? since.current + DETAILS_STATUS_HOLD - performance.now() : 0;
120
+ if (left <= 0) {
121
+ setSaid(next);
122
+ return;
123
+ }
124
+ const timer = window.setTimeout(() => setSaid(next), left);
125
+ return () => window.clearTimeout(timer);
126
+ }, [state, said]);
127
+ return (_jsxs("div", { className: "nx-form-buttons nx-form-buttons--end nx-crud-details__foot", children: [_jsxs("p", { className: "nx-form-status nx-form-status--muted", "aria-live": "polite", children: [said === "loading" && (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }), _jsx("span", { children: "Loading\u2026" })] })), said === "failed" && (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconInfoCircle }), _jsx("span", { children: "Not all fields loaded." })] }))] }), _jsx("div", { className: "nx-form-buttons__actions", children: children })] }));
128
+ }
68
129
  /**
69
130
  * A CRUD page in one component: a data table of a controller's records with its lists, Add and Edit popups, details,
70
131
  * and actions on one record or many, each shown only when its endpoint exists and the user may call it.
@@ -201,13 +262,14 @@ export function Crud(props) {
201
262
  return new Promise(() => undefined);
202
263
  const endpoint = crudListEndpoint(api, mode, paging);
203
264
  const sortColumn = query.sort ? latest.current.columns.find(column => column.key === query.sort.key) : undefined;
204
- const key = paging === "server" ? `${mode}|${query.search}|${query.sort ? `${query.sort.key}:${query.sort.direction}` : ""}|${query.page}|${query.pageSize}` : mode;
265
+ const filters = query.filters ? JSON.stringify(query.filters) : "";
266
+ const key = paging === "server" ? `${mode}|${query.search}|${query.sort ? `${query.sort.key}:${query.sort.direction}` : ""}|${query.page}|${query.pageSize}|${filters}` : mode;
205
267
  const kept = cache.get(key);
206
268
  if (kept)
207
269
  return kept;
208
270
  const options = { signal, toast: false, subject: plural };
209
271
  if (paging === "server")
210
- options.body = backend.pageRequest(query, sortColumn?.sortKey ?? sortColumn?.key);
272
+ options.body = backend.pageRequest(query, sortColumn?.sortKey ?? sortColumn?.key, filterRequest(query.filters, latest.current.table?.filterInputs ?? [], latest.current.columns));
211
273
  return browserApi.send(endpoint, options).then(answer => {
212
274
  if (answer.aborted)
213
275
  throw abortError();
@@ -229,27 +291,43 @@ export function Crud(props) {
229
291
  * A server page is not patched when a record joined it or left it: its order, its last row and its counts are the
230
292
  * server's, so that page is dropped and the next one loads. One whose rows only changed is written in place.
231
293
  */
232
- function syncCaches(changes) {
294
+ function syncCaches(changes, written) {
233
295
  const shown = modeRef.current;
234
296
  if (changes.length)
235
297
  cache.map((key, value) => patchCachedList(value, crudCacheMode(key), changes, row => table.keyOf(row), member));
236
298
  else
237
299
  cache.clear();
238
- // The list on screen is the table's own, every row of it, so it is written back whole rather than patched.
239
- if (paging === "client" && shown)
300
+ // The list on screen is the table's own, every row of it, so it is written back whole rather than patched - when
301
+ // the change was written into it. When it was not (see `holds`), the table is read again from the lists just
302
+ // patched, or loaded when it has none, and a load still out for it is stopped: it may have been asked before the
303
+ // change, and would land without it.
304
+ if (!written)
305
+ void table.reload({ quiet: true });
306
+ else if (paging === "client" && shown)
240
307
  cache.update(shown, table.getRows());
241
308
  }
242
- function afterChange(action, rows, changes) {
243
- syncCaches(changes);
309
+ /**
310
+ * Whether the table holds the list an action was asked from, every row of it, so the answer can be written into it.
311
+ * Not when the user moved to another list while a slow answer was out - the table holds that list then - and not
312
+ * while a list is loading: a switch keeps the old list's rows until the new ones land, and writing those back would
313
+ * file one list under another's name.
314
+ */
315
+ function holds(from) {
316
+ return (from === undefined || from === modeRef.current) && table.getState().status === "ready";
317
+ }
318
+ function afterChange(action, rows, changes, written) {
319
+ syncCaches(changes, written);
244
320
  const shown = modeRef.current;
245
321
  if (shown)
246
322
  latest.current.onChange?.({ action, rows, mode: shown });
247
323
  }
248
324
  function applyChange(action, rows, options = {}) {
249
- const { result, mass = false, markOn, values } = options;
325
+ const { result, mass = false, markOn, values, from } = options;
250
326
  const shown = modeRef.current;
251
327
  if (!shown)
252
328
  return;
329
+ // Every table write below is for the list the action was asked from; when the table holds another, none is made.
330
+ const write = holds(from);
253
331
  const answered = rows.length > 1 ? backend.answeredKeys(result, rowKey) : null;
254
332
  const done = answered ? rows.filter(row => answered.has(table.keyOf(row))) : rows;
255
333
  const changes = backend.answeredChanges(result, rowKey);
@@ -270,16 +348,18 @@ export function Crud(props) {
270
348
  next = { ...next, ...change };
271
349
  if (one)
272
350
  next = { ...next, ...one };
273
- if (leavesList(action, shown, backend.isFlagged(next)))
274
- leaving.push(key);
275
- else
276
- table.upsert(next, { flash: false });
351
+ if (write) {
352
+ if (leavesList(action, shown, backend.isFlagged(next)))
353
+ leaving.push(key);
354
+ else
355
+ table.upsert(next, { flash: false });
356
+ }
277
357
  updated.push(next);
278
358
  moved.push({ key, row: next, before: current });
279
359
  }
280
360
  table.remove(leaving);
281
361
  patchPins(updated);
282
- afterChange(action, updated, moved);
362
+ afterChange(action, updated, moved, write);
283
363
  return;
284
364
  }
285
365
  if (action === "pin") {
@@ -295,13 +375,13 @@ export function Crud(props) {
295
375
  if (one)
296
376
  next = { ...next, ...one };
297
377
  // A pin changes the section above the list, never the list itself: the record stays where it is, on its page.
298
- if (table.getRow(key))
378
+ if (write && table.getRow(key))
299
379
  table.upsert(next, { flash: false });
300
380
  updated.push(next);
301
381
  moved.push({ key, row: next, before: current });
302
382
  }
303
383
  applyPins(updated);
304
- afterChange(action, updated, moved);
384
+ afterChange(action, updated, moved, write);
305
385
  return;
306
386
  }
307
387
  if (action === "description") {
@@ -310,12 +390,13 @@ export function Crud(props) {
310
390
  const key = table.keyOf(row);
311
391
  const current = table.getRow(key) ?? row;
312
392
  const next = { ...current, ...values };
313
- table.upsert(next);
393
+ if (write)
394
+ table.upsert(next);
314
395
  updated.push(next);
315
396
  moved.push({ key, row: next, before: current });
316
397
  }
317
398
  patchPins(updated);
318
- afterChange(action, updated, moved);
399
+ afterChange(action, updated, moved, write);
319
400
  return;
320
401
  }
321
402
  // Trash, recover, delete and the move back to the trash take the record to another band, and only the answer
@@ -334,11 +415,11 @@ export function Crud(props) {
334
415
  }
335
416
  if (moved.length !== done.length)
336
417
  moved.length = 0;
337
- if (leavesList(action, shown))
418
+ if (write && leavesList(action, shown))
338
419
  table.remove(keys);
339
420
  if (LEFT_ACTIVE.has(action))
340
421
  dropPins(keys);
341
- afterChange(action, updated, moved);
422
+ afterChange(action, updated, moved, write);
342
423
  }
343
424
  /**
344
425
  * Records a page's own action changed, written exactly where the built-in ones write theirs. The handle's `apply`.
@@ -351,6 +432,10 @@ export function Crud(props) {
351
432
  const updated = [];
352
433
  const leaving = [];
353
434
  let pinned = false;
435
+ // The page asked from whichever list it did; the record's band decides, so only a table still loading is not
436
+ // written. A record that joined the list on screen - asked from another list, answered after a switch - is not in
437
+ // the table to be updated, so the table is read again from the patched lists instead.
438
+ let write = holds(undefined);
354
439
  for (const given of rows) {
355
440
  const next = { ...(table.getRow(table.keyOf(given)) ?? {}), ...given };
356
441
  const key = table.keyOf(next);
@@ -360,21 +445,24 @@ export function Crud(props) {
360
445
  // The record decides whether it is still in the list on screen; a backend that cannot say keeps it there.
361
446
  if (gone || member(next, shown) === false)
362
447
  leaving.push(key);
363
- else if (before)
448
+ else if (!before && member(next, shown))
449
+ write = false;
450
+ else if (before && write)
364
451
  table.upsert(next, { flash: false });
365
452
  if (before && backend.isPinned(next) !== backend.isPinned(before))
366
453
  pinned = true;
367
454
  updated.push(next);
368
455
  moved.push({ key, row: next, before, gone });
369
456
  }
370
- table.remove(leaving);
457
+ if (write)
458
+ table.remove(leaving);
371
459
  if (gone)
372
460
  dropPins(leaving);
373
461
  else if (pinned)
374
462
  applyPins(updated);
375
463
  else
376
464
  patchPins(updated);
377
- syncCaches(moved);
465
+ syncCaches(moved, write);
378
466
  }
379
467
  /* Row actions */
380
468
  async function runRowAction(action, row) {
@@ -382,6 +470,7 @@ export function Crud(props) {
382
470
  if (!endpoint)
383
471
  return;
384
472
  const key = rawKey(row);
473
+ const from = modeRef.current;
385
474
  const markOn = action === "flag" ? !backend.isFlagged(row) : action === "pin" ? !backend.isPinned(row) : undefined;
386
475
  if (action === "pin" && markOn && pinRoom(pinned?.length) < 1) {
387
476
  toast.warning(pinsFull());
@@ -390,7 +479,7 @@ export function Crud(props) {
390
479
  const options = { params: { id: key, [rowKey]: key }, subject: labelOf(row), action: crudMessageAction(action, markOn) };
391
480
  const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
392
481
  if (answer?.ok)
393
- applyChange(action, [row], { result: answer.result, markOn });
482
+ applyChange(action, [row], { result: answer.result, markOn, from });
394
483
  }
395
484
  async function runMassAction(action, rows) {
396
485
  if (action === "description") {
@@ -424,10 +513,11 @@ export function Crud(props) {
424
513
  }
425
514
  const messageAction = action === "flag" && markOn === undefined ? TOGGLED_FLAG : crudMessageAction(action, markOn);
426
515
  const options = { body: backend.massRequest(action, sent.map(rawKey)), subject, plural: props.subjectPlural, count: sent.length, action: messageAction };
516
+ const from = modeRef.current;
427
517
  const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
428
518
  if (!answer?.ok)
429
519
  return;
430
- applyChange(action, sent, { result: answer.result, mass: true, markOn });
520
+ applyChange(action, sent, { result: answer.result, mass: true, markOn, from });
431
521
  table.clearSelection();
432
522
  if (over > 0)
433
523
  toast.warning(`${sent.length} of ${sent.length + over} ${plural} pinned: you can pin up to ${maxPins}.`);
@@ -510,9 +600,10 @@ export function Crud(props) {
510
600
  return;
511
601
  const before = table.getRow(key);
512
602
  const next = { ...(before ?? record), ...full };
513
- if (before)
603
+ const write = holds(undefined);
604
+ if (write && before)
514
605
  table.upsert(next, { flash: false });
515
- syncCaches([{ key, row: next, before }]);
606
+ syncCaches([{ key, row: next, before }], write);
516
607
  }
517
608
  function onSaved(action, row, result, values) {
518
609
  // The record as the server now has it: what was sent, over the row it came from, with whatever the answer added.
@@ -523,9 +614,11 @@ export function Crud(props) {
523
614
  if (action === "edit") {
524
615
  const key = table.keyOf(record);
525
616
  const before = table.getRow(key);
526
- table.upsert(record);
617
+ const write = holds(undefined);
618
+ if (write)
619
+ table.upsert(record);
527
620
  patchPins([record]);
528
- afterChange("edit", [record], key ? [{ key, row: record, before }] : []);
621
+ afterChange("edit", [record], key ? [{ key, row: record, before }] : [], write);
529
622
  return;
530
623
  }
531
624
  // A new record belongs to the list of every record. An answer that is only the new key still gives it one, so
@@ -536,13 +629,11 @@ export function Crud(props) {
536
629
  record[rowKey] = given;
537
630
  }
538
631
  const key = table.keyOf(record);
539
- if (shown === "normal") {
540
- if (key)
541
- table.upsert(record);
542
- else
543
- void table.reload({ quiet: true });
544
- }
545
- afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : []);
632
+ // Without a key the lists are dropped and the one on screen loads again (`syncCaches`).
633
+ const write = Boolean(key) && holds(undefined);
634
+ if (write && shown === "normal")
635
+ table.upsert(record);
636
+ afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : [], write);
546
637
  if (key && !answered && api.details)
547
638
  void fillSaved(key, record);
548
639
  }
@@ -553,6 +644,7 @@ export function Crud(props) {
553
644
  const key = table.keyOf(row);
554
645
  const canEdit = allowed.edit && modeRef.current !== undefined && CRUD_ROW_ACTIONS[modeRef.current].includes("edit");
555
646
  const endpoint = api.details;
647
+ const status = detailsLoad(endpoint ? "loading" : "done");
556
648
  // The panel's record: the row at once, so it has something to show while the full one loads, and that one after.
557
649
  const shown = typeof details?.aside === "function" ? new FormStore() : null;
558
650
  shown?.load(row, { draft: false });
@@ -568,18 +660,28 @@ export function Crud(props) {
568
660
  // draw: a panel that renders at once (a Copy button) would otherwise take it, and one that loads would not.
569
661
  initialFocus: aside ? ".nx-popup__foot button" : undefined,
570
662
  content: (_jsx(CrudFormBody, { aside: aside, details: true, children: _jsx(CrudDetails, { row: row, columns: details?.columns ?? latest.current.columns, render: details?.render, load: endpoint
571
- ? signal => browserApi
572
- .get(endpoint, { params: { id: rawKey(row), [rowKey]: rawKey(row) }, signal, toast: false })
573
- .then(unwrapApiResult)
574
- .then(full => {
575
- // Only when it adds something: a panel that renders from the record would otherwise ask again
576
- // for exactly what it is already showing.
577
- if (shown && isRecord(full) && addsToRow(row, full))
578
- shown.load({ ...row, ...full }, { draft: false });
579
- return full;
580
- })
663
+ ? signal => {
664
+ const loading = browserApi
665
+ .get(endpoint, { params: { id: rawKey(row), [rowKey]: rawKey(row) }, signal, toast: false })
666
+ .then(unwrapApiResult)
667
+ .then(full => {
668
+ // Only when it adds something: a panel that renders from the record would otherwise ask again
669
+ // for exactly what it is already showing.
670
+ if (shown && isRecord(full) && addsToRow(row, full))
671
+ shown.load({ ...row, ...full }, { draft: false });
672
+ return full;
673
+ });
674
+ // The footer follows the very promise the details do, with the same test for a popup closed meanwhile.
675
+ void loading.then(() => status.set("done"), error => {
676
+ if (!signal.aborted && !isAbortError(error))
677
+ status.set("failed");
678
+ });
679
+ return loading;
680
+ }
581
681
  : undefined }) })),
582
- footer: popup => (_jsxs(_Fragment, { children: [canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: () => {
682
+ footer: popup => (_jsxs(DetailsFooter, { load: status, children: [details?.actions
683
+ ?.filter(action => (!action.endpoint || (ready && can(action.endpoint))) && !action.hidden?.(row))
684
+ .map(action => (_jsx(Button, { icon: action.icon, "data-action": action.id, onClick: () => action.onSelect(table.getRow(key) ?? row, popup), children: action.label }, action.id))), canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: () => {
583
685
  popup.close();
584
686
  void openForm("edit", table.getRow(key) ?? row);
585
687
  }, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: () => popup.close(), children: "Close" })] })),
@@ -624,9 +726,9 @@ export function Crud(props) {
624
726
  flag: flagged ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
625
727
  pin: isPinned ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
626
728
  trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
627
- recover: { label: "Recover", description: "Back to the active list", icon: _jsx(InlineIcon, { icon: iconRestore }), inline: true },
729
+ recover: { label: "Recover", description: "Back to the active list", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), inline: true },
628
730
  delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
629
- trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconRestore }), inline: true },
731
+ trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), inline: true },
630
732
  };
631
733
  for (const action of crudRowActions(mode, candidate => allowed[candidate], trashless)) {
632
734
  if (action === "view" && latest.current.details === false)
@@ -650,9 +752,9 @@ export function Crud(props) {
650
752
  flag: flagged === rows.length ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: flagged ? "Flag / unflag" : "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
651
753
  pin: unpinned === 0 ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
652
754
  trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
653
- recover: { label: "Recover", icon: _jsx(InlineIcon, { icon: iconRestore }) },
755
+ recover: { label: "Recover", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
654
756
  delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
655
- trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconRestore }) },
757
+ trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
656
758
  description: { label: "Description", icon: _jsx(InlineIcon, { icon: iconNotes }) },
657
759
  };
658
760
  list.push({ ...info[action], id: action, onSelect });
@@ -691,12 +793,29 @@ export function Crud(props) {
691
793
  return (_jsxs("div", { id: id, className: cx("nx-crud", "nx-crud--denied", className), style: style, role: "status", children: [_jsx(InlineIcon, { icon: iconLock, className: "nx-crud__denied-icon" }), _jsxs("p", { className: "nx-crud__denied-title", children: ["You do not have permission to see ", plural, "."] }), _jsx("p", { className: "nx-crud__denied-detail", children: "Ask an administrator for access." })] }));
692
794
  }
693
795
  const modeOptions = modes.map(value => ({ value, label: MODE_INFO[value].label, icon: _jsx(InlineIcon, { icon: MODE_INFO[value].icon }) }));
694
- const addButton = allowed.create ? (_jsxs(Button, { variant: "primary", size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onClick: () => void run.current.openForm("create", null), children: ["Add ", subject] })) : null;
796
+ const add = allowed.create ? { label: `Add ${subject}`, onClick: () => void run.current.openForm("create", null) } : undefined;
797
+ // In the main list, select again by pin and by flag, the way the mass actions act on them.
798
+ const selectBy = mode === "normal"
799
+ ? [
800
+ ...(massAllowed.pin
801
+ ? [
802
+ { label: "Pinned", icon: _jsx(InlineIcon, { icon: iconPin }), test: (row) => backend.isPinned(row) },
803
+ { label: "Not pinned", icon: _jsx(InlineIcon, { icon: iconPinnedOff }), test: (row) => !backend.isPinned(row) },
804
+ ]
805
+ : []),
806
+ ...(massAllowed.flag
807
+ ? [
808
+ { label: "Flagged", icon: _jsx(InlineIcon, { icon: iconFlag }), test: (row) => backend.isFlagged(row) },
809
+ { label: "Not flagged", icon: _jsx(InlineIcon, { icon: iconFlagOff }), test: (row) => !backend.isFlagged(row) },
810
+ ]
811
+ : []),
812
+ ]
813
+ : [];
695
814
  const selectable = props.massActions !== false && Boolean(mode) && (crudMassActions(mode, action => massAllowed[action], massTrashless).length > 0 || Boolean(props.massActions));
696
- return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, columns: columns, load: load, loadKey: mode ?? "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "selected", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: _jsxs(_Fragment, { children: [props.toolbar, addButton] }), selectable: selectable, massActions: massActionsFor, rowActions: rowActions, pinnedRows: mode === "normal" && pinsOn ? (pinned ?? undefined) : undefined, pinnedLoading: pinsLoading, pinnedLabel: `Pinned ${plural}`, pinnedMax: maxPins, onRowOpen: onRowOpen, openOnClick: tableOptions?.openOnClick, onReload: () => {
815
+ return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, columns: columns, load: load, loadKey: mode ?? "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "auto", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: props.toolbar, add: add, selectable: selectable, massActions: massActionsFor, selectBy: selectBy, rowActions: rowActions, pinnedRows: mode === "normal" && pinsOn ? (pinned ?? undefined) : undefined, pinnedLoading: pinsLoading, pinnedLabel: `Pinned ${plural}`, pinnedMax: maxPins, onRowOpen: onRowOpen, openOnClick: tableOptions?.openOnClick, onReload: () => {
697
816
  cache.clear();
698
817
  reloadPins();
699
- }, emptyMessage: tableOptions?.emptyMessage ?? (mode ? MODE_INFO[mode].empty(plural) : undefined), emptyAction: mode === "normal" ? addButton : undefined, searchPlaceholder: tableOptions?.searchPlaceholder ?? `Search ${plural}…`, stateKey: tableOptions?.stateKey ?? `crud:${name}`, pageSize: tableOptions?.pageSize, pageSizes: tableOptions?.pageSizes, defaultSort: tableOptions?.defaultSort, numbered: tableOptions?.numbered, maxHeight: tableOptions?.maxHeight, fill: tableOptions?.fill, size: tableOptions?.size, maxSelection: tableOptions?.maxSelection, columnsMenu: tableOptions?.columnsMenu, searchDelay: tableOptions?.searchDelay, reorderable: tableOptions?.reorderable, resizable: tableOptions?.resizable, pinnable: tableOptions?.pinnable, moreActions: tableOptions?.moreActions, exportable: tableOptions?.exportable, printable: tableOptions?.printable, exportName: tableOptions?.exportName, onImport: tableOptions?.onImport, excel: tableOptions?.excel && {
818
+ }, emptyMessage: tableOptions?.emptyMessage ?? (mode ? MODE_INFO[mode].empty(plural) : undefined), emptyAction: mode === "normal" ? undefined : null, searchPlaceholder: tableOptions?.searchPlaceholder ?? `Search ${plural}…`, stateKey: tableOptions?.stateKey ?? `crud:${name}`, pageSize: tableOptions?.pageSize, pageSizes: tableOptions?.pageSizes, defaultSort: tableOptions?.defaultSort, numbered: tableOptions?.numbered, maxHeight: tableOptions?.maxHeight, fill: tableOptions?.fill, size: tableOptions?.size, maxSelection: tableOptions?.maxSelection, columnsMenu: tableOptions?.columnsMenu, searchDelay: tableOptions?.searchDelay, filterInputs: tableOptions?.filterInputs, reorderable: tableOptions?.reorderable, resizable: tableOptions?.resizable, pinnable: tableOptions?.pinnable, moreActions: tableOptions?.moreActions, exportable: tableOptions?.exportable, printable: tableOptions?.printable, exportName: tableOptions?.exportName, onImport: tableOptions?.onImport, excel: tableOptions?.excel && {
700
819
  ...tableOptions.excel,
701
820
  // A spreadsheet wrote records straight into the database: every list this CRUD kept is out of date.
702
821
  onImported: () => {
@@ -6,7 +6,11 @@ export interface CrudDetailsProps<TRow extends FormValues = FormValues> {
6
6
  row: TRow;
7
7
  /** The fields, as the table's columns describe them; hidden columns included. */
8
8
  columns: readonly TableColumn<TRow>[];
9
- /** Loads the full record (fields the list leaves out); its values replace the row's when they come. */
9
+ /**
10
+ * Loads the full record (fields the list leaves out); its values replace the row's when they come. Meanwhile a field
11
+ * the row lacks keeps its line blank, and reads "Not loaded" if the load fails; the details say nothing else about
12
+ * the load (`aria-busy` while it runs), so the popup around them never changes size for it.
13
+ */
10
14
  load?: (signal: AbortSignal) => Promise<Partial<TRow> | null | undefined>;
11
15
  /** Draws the details instead of the list of fields. */
12
16
  render?: (row: TRow) => ReactNode;
@@ -1,18 +1,30 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconInfoCircle, iconLoader2, InlineIcon } from "nexus-icons";
4
- import { useEffect, useMemo, useState } from "react";
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { motionAllowed } from "../../Helpers/AnimationHelpers.js";
5
5
  import { isAbortError } from "../../Helpers/ApiResponses.js";
6
6
  import { cx } from "../../Helpers/ClassHelpers.js";
7
7
  import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
8
+ import { readPath } from "../../Helpers/TableHelpers.js";
9
+ import { useNumberLocale } from "../../Helpers/UserPreferences.js";
8
10
  import { TableCellValue } from "../Viewers/DataTableParts.js";
11
+ /**
12
+ * An element's height as its `height` would set it: unrounded, unlike `offsetHeight`, so an unroll between two of
13
+ * them starts and ends exactly where the details are and the popup never steps by a fraction of a pixel. NaN while
14
+ * not drawn.
15
+ */
16
+ const usedHeight = (element) => parseFloat(getComputedStyle(element).height);
9
17
  /** A record's fields as label and value, two to a line where there is room, in the formats of the table's columns. */
10
18
  export function CrudDetails({ row, columns, load, render }) {
11
19
  const [record, setRecord] = useState(row);
12
20
  const [state, setState] = useState(load ? "loading" : "done");
21
+ const rootRef = useRef(null);
22
+ // The details' height just before the full record went in, kept only when the change may be animated.
23
+ const grownFrom = useRef(null);
13
24
  const preferences = useDatePreferences();
14
25
  const timeZone = useBrowserTimeZone();
15
- const options = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined }), [preferences, timeZone]);
26
+ const locale = useNumberLocale();
27
+ const options = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined, locale }), [preferences, timeZone, locale]);
16
28
  useEffect(() => {
17
29
  if (!load)
18
30
  return;
@@ -20,8 +32,11 @@ export function CrudDetails({ row, columns, load, render }) {
20
32
  load(controller.signal).then(full => {
21
33
  if (controller.signal.aborted)
22
34
  return;
23
- if (full)
35
+ if (full) {
36
+ if (motionAllowed() && rootRef.current)
37
+ grownFrom.current = usedHeight(rootRef.current);
24
38
  setRecord(current => ({ ...current, ...full }));
39
+ }
25
40
  setState("done");
26
41
  }, error => {
27
42
  if (controller.signal.aborted || isAbortError(error))
@@ -32,5 +47,22 @@ export function CrudDetails({ row, columns, load, render }) {
32
47
  // The record loads once per popup.
33
48
  // eslint-disable-next-line react-hooks/exhaustive-deps
34
49
  }, []);
35
- return (_jsxs("div", { className: "nx-crud-details", "aria-busy": state === "loading" || undefined, children: [state === "loading" && (_jsxs("p", { className: "nx-crud-details__note", role: "status", children: [_jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }), "Loading the full record\u2026"] })), state === "failed" && (_jsxs("p", { className: "nx-crud-details__note", role: "status", children: [_jsx(InlineIcon, { icon: iconInfoCircle }), "The full record could not be loaded; these are the values from the list."] })), render ? (render(record)) : (_jsx("dl", { className: "nx-crud-details__list", children: columns.map(column => (_jsxs("div", { className: cx("nx-crud-details__item", column.wrap && "nx-crud-details__item--wide"), children: [_jsx("dt", { className: "nx-crud-details__label", children: column.label }), _jsx("dd", { className: "nx-crud-details__value", children: _jsx(TableCellValue, { column: column, row: record, options: options }) })] }, column.key))) }))] }));
50
+ // A record that makes the details taller (a long note the list did not have) unrolls to its height when motion is
51
+ // allowed, as maximize does; without motion it changes at once. Nothing waits on it.
52
+ useLayoutEffect(() => {
53
+ const from = grownFrom.current;
54
+ grownFrom.current = null;
55
+ const root = rootRef.current;
56
+ if (!from || !root || typeof root.animate !== "function")
57
+ return;
58
+ const to = usedHeight(root);
59
+ if (!to || Math.abs(to - from) < 1)
60
+ return;
61
+ const animation = root.animate([{ height: `${from}px`, overflow: "clip" }, { height: `${to}px`, overflow: "clip" }], { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)" });
62
+ return () => animation.cancel();
63
+ }, [record]);
64
+ // A field the list's row does not carry can only be filled by the full record. Columns that draw or work out their
65
+ // own value (`render`, `value`) draw as always, because only they know what they read.
66
+ const awaited = (column) => state !== "done" && !column.render && !column.value && readPath(record, column.key) === undefined;
67
+ return (_jsx("div", { ref: rootRef, className: "nx-crud-details", "aria-busy": state === "loading" || undefined, children: render ? (render(record)) : (_jsx("dl", { className: "nx-crud-details__list", children: columns.map(column => (_jsxs("div", { className: cx("nx-crud-details__item", column.wrap && "nx-crud-details__item--wide"), children: [_jsx("dt", { className: "nx-crud-details__label", children: column.label }), _jsx("dd", { className: "nx-crud-details__value", children: !awaited(column) ? _jsx(TableCellValue, { column: column, row: record, options: options }) : state === "failed" ? _jsx("span", { className: "nx-crud-details__missing", children: "Not loaded" }) : null })] }, column.key))) })) }));
36
68
  }
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import { type ReactNode, type Ref } from "react";
2
2
  import type { ExcelEndpoints, ExcelImportAnswer } from "../../Interfaces/ExcelInterfaces.ts";
3
3
  import { type ExcelCallOptions } from "../../Services/ExcelApi.ts";
4
4
  import { type PopupHandle } from "../Layouts/PopupHost.tsx";
@@ -18,24 +18,40 @@ export interface ExcelImportProps {
18
18
  templateDownload?: false;
19
19
  /** The tallest each editor grows before its rows scroll. Default 320. */
20
20
  maxHeight?: number;
21
+ /**
22
+ * `"file"` (the default): a filled template is sent to be checked, and its rows come back to be gone over.
23
+ * `"bulk"`: no file - the rows are typed here, from one empty row, and imported with the same checks. A sheet with a
24
+ * required list cannot be typed in (its key is picked in the spreadsheet), and the panel says so.
25
+ */
26
+ mode?: "file" | "bulk";
27
+ /** What closing the panel would lose, so a window can ask first: `openExcelImport` passes one. */
28
+ ref?: Ref<ExcelImportHandle>;
21
29
  className?: string;
22
30
  }
31
+ /** What `ref` gives on an import panel. */
32
+ export interface ExcelImportHandle {
33
+ /** The rows only this screen has - typed here, or a sheet's row edited here - which closing the panel loses. */
34
+ unsavedRows: () => number;
35
+ }
23
36
  /**
24
- * The three steps of an import in one panel: the template, the file, and the review.
37
+ * The three steps of an import in one panel: the template, the file, and the review - or, for a bulk insert, the rows
38
+ * typed in and the import.
25
39
  *
26
40
  * @example
27
41
  * <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={() => table.reload()} />
42
+ * <ExcelImport endpoints={REGION_SHEET} subject="region" mode="bulk" onImported={() => table.reload()} />
28
43
  */
29
- export declare function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight, className }: ExcelImportProps): import("react").JSX.Element;
44
+ export declare function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight, mode, ref, className }: ExcelImportProps): import("react").JSX.Element;
30
45
  /** Options of the import popup: the panel's, plus what the window looks like. */
31
- export interface OpenExcelImportOptions extends Omit<ExcelImportProps, "onClose"> {
32
- /** The window's title. Default: "Import <subjects> from a spreadsheet". */
46
+ export interface OpenExcelImportOptions extends Omit<ExcelImportProps, "onClose" | "ref"> {
47
+ /** The window's title. Default: "Import <subjects> from a spreadsheet", or "Bulk insert <subjects>". */
33
48
  title?: ReactNode;
34
- /** One popup per key. Default: the import endpoint's path, so one model opens one. */
49
+ /** One popup per key. Default: the import endpoint's path and the mode, so one model opens one of each. */
35
50
  popupKey?: string;
36
51
  }
37
52
  /**
38
- * Opens the import panel as a window. This is the one line a data table's Import entry needs.
53
+ * Opens the import panel as a window. This is the one line a data table's Import entry needs - and, with
54
+ * `mode: "bulk"`, its Bulk insert entry.
39
55
  *
40
56
  * @example onImport={() => openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload })}
41
57
  */