@visns-studio/visns-components 6.6.4 → 6.15.1

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 (98) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +181 -38
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/styles/global-datagrid.css +27 -2
  78. package/src/components/utils/buildEnv.js +67 -0
  79. package/src/components/utils/displayValue.js +94 -0
  80. package/src/components/utils/useDensity.js +345 -9
  81. package/src/components/vault/OtpChip.jsx +208 -0
  82. package/src/components/vault/PasswordGenerator.jsx +145 -0
  83. package/src/components/vault/QrScanner.jsx +326 -0
  84. package/src/components/vault/VaultAccessLog.jsx +225 -0
  85. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  86. package/src/components/vault/VaultEntryForm.jsx +740 -0
  87. package/src/components/vault/VaultManager.jsx +1000 -0
  88. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  89. package/src/components/vault/qrDecode.js +257 -0
  90. package/src/components/vault/useDebouncedValue.js +22 -0
  91. package/src/components/vault/useVaultReveal.js +160 -0
  92. package/src/components/vault/vaultClipboard.js +33 -0
  93. package/src/components/vault/vaultEndpoints.js +40 -0
  94. package/src/components/vault/vaultFit.js +116 -0
  95. package/src/components/vault/vaultHelpers.js +547 -0
  96. package/src/components/vault/vaultNavigation.jsx +58 -0
  97. package/src/components/vault/vaultOtp.js +105 -0
  98. package/src/index.js +233 -1
package/package.json CHANGED
@@ -28,6 +28,7 @@
28
28
  "framer-motion": "^12.38.0",
29
29
  "html-react-parser": "^5.2.17",
30
30
  "html2canvas": "^1.4.1",
31
+ "jsqr": "^1.4.0",
31
32
  "lodash": "^4.18.1",
32
33
  "lodash.debounce": "^4.0.8",
33
34
  "lucide-react": "^0.544.0",
@@ -36,6 +37,7 @@
36
37
  "motion": "^12.38.0",
37
38
  "numeral": "^2.0.6",
38
39
  "pluralize": "^8.0.0",
40
+ "prop-types": "^15.8.1",
39
41
  "qrcode.react": "^4.2.0",
40
42
  "quill-image-uploader": "^1.3.0",
41
43
  "react-big-calendar": "^1.19.4",
@@ -81,17 +83,19 @@
81
83
  "css-loader": "^7.1.4",
82
84
  "css-minimizer-webpack-plugin": "^7.0.4",
83
85
  "mini-css-extract-plugin": "^2.10.2",
86
+ "pngjs": "^7.0.0",
87
+ "qrcode": "^1.5.4",
84
88
  "react": "^18.3.1",
85
89
  "react-dom": "^18.3.1",
86
90
  "sass": "^1.99.0",
87
91
  "sass-loader": "^16.0.7"
88
92
  },
89
93
  "peerDependencies": {
90
- "react": "^17.0.0 || ^18.0.0",
91
- "react-dom": "^17.0.0 || ^18.0.0"
94
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
95
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
92
96
  },
93
97
  "name": "@visns-studio/visns-components",
94
- "version": "6.6.4",
98
+ "version": "6.15.1",
95
99
  "description": "Various packages to assist in the development of our Custom Applications.",
96
100
  "main": "src/index.js",
97
101
  "files": [
@@ -100,7 +104,9 @@
100
104
  ],
101
105
  "scripts": {
102
106
  "build": "vite build",
103
- "test": "echo \"Error: no test specified\" && exit 1"
107
+ "test": "node --test tests/*.test.mjs",
108
+ "dev:vault": "vite --config dev/vite.config.js",
109
+ "dev:sms": "FIXTURE=sms vite --config dev/vite.config.js"
104
110
  },
105
111
  "repository": {
106
112
  "type": "git",
@@ -30,6 +30,7 @@ import React, {
30
30
  useState,
31
31
  } from 'react';
32
32
  import { useNavigate, useParams } from 'react-router-dom';
33
+ import { buildEnv } from './utils/buildEnv';
33
34
  import debounce from 'lodash.debounce';
34
35
  import moment from 'moment';
35
36
  import 'reactjs-popup/dist/index.css';
@@ -43,7 +44,11 @@ import NativeDateFilterEditor from './columns/NativeDateFilterEditor';
43
44
  import { toast } from 'react-toastify';
44
45
  import fetchUtil from '../utils/fetchUtil';
45
46
  import { confirmDialog } from './utils/ConfirmDialog';
46
- import { useDensity } from './utils/useDensity';
47
+ import {
48
+ useDensity,
49
+ useAvailableHeight,
50
+ rowsForHeight,
51
+ } from './utils/useDensity';
47
52
  import { DEFAULT_INTELLIGENT_SORTING_CONFIG } from '../utils/relationshipSortingUtils';
48
53
  import {
49
54
  extractColumnsMetadata,
@@ -873,6 +878,22 @@ const DataGrid = forwardRef(
873
878
  debugLog('[DataGrid] Initial limit set to:', initialLimit);
874
879
  return initialLimit;
875
880
  });
881
+
882
+ // Set the moment the user picks a size from the pager's "Results per
883
+ // page" control. From then on nothing auto-sizes the page behind their
884
+ // back — not the profile default, not a window resize. Deliberately a
885
+ // ref rather than state: it must not itself trigger a re-render, and
886
+ // the sizing effect reads it without wanting to re-run when it flips.
887
+ const userChoseLimitRef = useRef(false);
888
+ // The current limit, readable from the sizing effect without listing
889
+ // `limit` as a dependency (which would re-run it on every page change).
890
+ const limitRef = useRef(limit);
891
+ limitRef.current = limit;
892
+
893
+ const handleLimitChange = useCallback((next) => {
894
+ userChoseLimitRef.current = true;
895
+ setLimit(next);
896
+ }, []);
876
897
  const [search, setSearch] = useState('');
877
898
 
878
899
  // This component instance survives client-side route changes — React Router
@@ -907,8 +928,12 @@ const DataGrid = forwardRef(
907
928
  userProfile_settings_rows_per_page: userProfile?.settings?.rows_per_page,
908
929
  currentLimit: limit,
909
930
  });
910
- // Only apply the user's preference once, when userProfile first becomes available
911
- if (!initialLimitApplied.current) {
931
+ // Only apply the user's preference once, when userProfile first
932
+ // becomes available — and never over a size the user has picked
933
+ // from the pager since. This seeds the limit for the first fetch;
934
+ // the sizing effect below replaces it as soon as the grid has
935
+ // measured itself against the window.
936
+ if (!initialLimitApplied.current && !userChoseLimitRef.current) {
912
937
  const userLimit = userProfile?.rows_per_page || userProfile?.settings?.rows_per_page;
913
938
  debugLog('[DataGrid] Checking userLimit:', userLimit);
914
939
  if (userLimit && userLimit > 0) {
@@ -3099,10 +3124,10 @@ const DataGrid = forwardRef(
3099
3124
 
3100
3125
  const handleCoding = (c, data) => {
3101
3126
  const icons = [];
3102
- const assetPath =
3103
- import.meta.env.REACT_APP_ASSET_PATH !== undefined
3104
- ? import.meta.env.REACT_APP_ASSET_PATH
3105
- : '';
3127
+ // Read through buildEnv, not import.meta.env directly: the
3128
+ // latter is Vite's interface and throws a TypeError under
3129
+ // webpack, which took the whole grid down in the Next.js portal.
3130
+ const assetPath = buildEnv('REACT_APP_ASSET_PATH', '');
3106
3131
 
3107
3132
  const addIcon = (src, icon, alt, tooltipContent, key) => {
3108
3133
  if (src && src !== '') {
@@ -3920,7 +3945,16 @@ const DataGrid = forwardRef(
3920
3945
  // clamped columns exceed the container (narrow
3921
3946
  // screens) — columns then render a few px wide
3922
3947
  // with no horizontal scrollbar ever appearing.
3923
- minWidth: column.minWidth ?? grid.columnMinWidth,
3948
+ // A date filter needs room for its locale mask
3949
+ // ("dd/mm/yyyy" + the picker glyph); below ~140px
3950
+ // the native input clips its own value.
3951
+ minWidth:
3952
+ column.minWidth ??
3953
+ (['date', 'datetime', 'datetime-local'].includes(
3954
+ column.type
3955
+ )
3956
+ ? Math.max(grid.columnMinWidth, 140)
3957
+ : grid.columnMinWidth),
3924
3958
  maxWidth: column.maxWidth ?? undefined,
3925
3959
  textAlign: column.textAlign ?? undefined,
3926
3960
  textVerticalAlign:
@@ -5193,11 +5227,42 @@ const DataGrid = forwardRef(
5193
5227
  // Data Grid Styling
5194
5228
  const [windowHeight, setWindowHeight] = useState(window.innerHeight);
5195
5229
  const [dataCount, setDataCount] = useState(0);
5230
+
5231
+ // Pages that mount the grid directly (rather than through GenericIndex)
5232
+ // pass no gridHeight, and fitToRows deliberately never grows past the
5233
+ // rows - so on a tall screen the grid sat at a few rows' height and
5234
+ // scrolled inside itself. The grid now measures what is left between
5235
+ // its own top edge and the bottom of the viewport; fitToRows remains
5236
+ // the first-paint fallback.
5237
+ //
5238
+ // Measured from `.dataGridContainer` rather than from the whole
5239
+ // component, so the search/create bar and the legend above it are part
5240
+ // of `top` instead of being asked for a second time below.
5241
+ // `measureChrome` subtracts what the *page* needs under the grid — the
5242
+ // card's padding and border, the page gutter, any section rendered
5243
+ // after it — which used to be a flat 28px guess that was wrong for any
5244
+ // page nesting its grid differently.
5245
+ const containerRef = useRef(null);
5246
+ const measuredHeight = useAvailableHeight(containerRef, {
5247
+ bottomGutter: 0,
5248
+ measureChrome: true,
5249
+ recomputeKey: windowHeight,
5250
+ });
5251
+ const resolvedHeight =
5252
+ gridHeight && gridHeight > 0
5253
+ ? gridHeight
5254
+ : measuredHeight != null && measuredHeight > 0
5255
+ ? measuredHeight
5256
+ : null;
5257
+
5258
+ // Both minHeight AND height: with minHeight alone the grid settles at
5259
+ // its content height on a tall screen. An explicit height makes it
5260
+ // take the room measured for it.
5261
+ const initialHeight =
5262
+ resolvedHeight ?? fitToRows(dataCount, windowHeight);
5196
5263
  const [gridStyle, setGridStyle] = useState({
5197
- minHeight:
5198
- gridHeight && gridHeight > 0
5199
- ? gridHeight
5200
- : fitToRows(dataCount, windowHeight),
5264
+ minHeight: initialHeight,
5265
+ height: initialHeight,
5201
5266
  boxShadow: 'none',
5202
5267
  ...(hoverColor ? { '--hover-color': hoverColor } : {}),
5203
5268
  });
@@ -5205,39 +5270,116 @@ const DataGrid = forwardRef(
5205
5270
  style: style ? style : {},
5206
5271
  };
5207
5272
 
5273
+ /**
5274
+ * The part of the grid's height that is not data rows: the column
5275
+ * header, the filter row (only some pages have one), the footer rows
5276
+ * and the pagination toolbar.
5277
+ *
5278
+ * Read off the rendered grid rather than assumed, because it changes
5279
+ * with density, with `enableFiltering`, and with whether the page shows
5280
+ * a footer — and because the pager *is* inside the grid element, so
5281
+ * getting this wrong is exactly what leaves the last row hidden behind
5282
+ * it or the pager stranded under a band of empty body.
5283
+ *
5284
+ * Returns null on the first pass, before the grid is in the DOM; the
5285
+ * caller falls back to GRID_CHROME.
5286
+ */
5287
+ const measureGridChrome = () => {
5288
+ const root = containerRef.current;
5289
+
5290
+ if (!root || typeof root.querySelector !== 'function') {
5291
+ return null;
5292
+ }
5293
+
5294
+ const gridEl = root.querySelector('.InovuaReactDataGrid');
5295
+ const body = root.querySelector('.InovuaReactDataGrid__body');
5296
+
5297
+ if (!gridEl || !body) {
5298
+ return null;
5299
+ }
5300
+
5301
+ const chrome =
5302
+ gridEl.getBoundingClientRect().height -
5303
+ body.getBoundingClientRect().height;
5304
+
5305
+ return chrome > 0 ? chrome : null;
5306
+ };
5307
+
5208
5308
  useEffect(() => {
5209
5309
  const getMinimumHeight = () => {
5210
- if (gridHeight > 0) return gridHeight;
5310
+ if (resolvedHeight > 0) return resolvedHeight;
5211
5311
  return fitToRows(dataCount, window.innerHeight);
5212
5312
  };
5213
5313
 
5214
5314
  const minHeight = getMinimumHeight();
5215
- setGridStyle((prevState) => ({ ...prevState, minHeight }));
5216
-
5217
- // Priority for limit:
5218
- // 1. User's rows_per_page preference (already applied via initialLimitApplied ref)
5219
- // 2. ajaxSetting.take if >= 35
5220
- // 3. Calculated rows based on height (fallback)
5221
-
5222
- // Only auto-calculate limit if user hasn't set a preference
5223
- const userPreferredLimit = userProfile?.rows_per_page || userProfile?.settings?.rows_per_page;
5315
+ setGridStyle((prevState) => ({
5316
+ ...prevState,
5317
+ minHeight,
5318
+ height: minHeight,
5319
+ }));
5224
5320
 
5225
- if (userPreferredLimit && userPreferredLimit > 0) {
5226
- // User has a preference, don't override it
5227
- debugLog('[DataGrid] Respecting user preference, not auto-calculating limit:', userPreferredLimit);
5228
- } else if (ajaxSetting?.take >= 35) {
5321
+ // How many rows to ask for.
5322
+ //
5323
+ // The rule used to be "if the profile has a rows_per_page, never
5324
+ // auto-calculate". That is a stored default, not a decision anyone
5325
+ // made — it is 25 for most staff — so every index page on a 1080p
5326
+ // screen drew 25 rows and then several hundred px of empty grid
5327
+ // body under them, with the pager stranded at the bottom of it.
5328
+ //
5329
+ // The profile value now *seeds* the limit (see the initial state
5330
+ // and the effect that applies it once the profile arrives) and
5331
+ // nothing more. Once the grid has measured itself against the
5332
+ // window, the measurement wins — unless the user has picked a size
5333
+ // from the pager's "Results per page" control in this session, in
5334
+ // which case their choice is final for as long as the grid is
5335
+ // mounted. Whatever the pager does about persisting that choice to
5336
+ // the profile is unchanged.
5337
+ //
5338
+ // A page that passes an explicit `gridHeight` is not sized to the
5339
+ // window at all (a grid in a modal, a secondary grid on a detail
5340
+ // page), so it keeps the old order: profile preference, then a
5341
+ // configured `take`, then the arithmetic.
5342
+ const autoSizedToWindow = !(gridHeight && gridHeight > 0);
5343
+ const userPreferredLimit =
5344
+ userProfile?.rows_per_page ||
5345
+ userProfile?.settings?.rows_per_page;
5346
+
5347
+ if (userChoseLimitRef.current) {
5348
+ debugLog(
5349
+ '[DataGrid] Honouring the size the user picked this session:',
5350
+ limitRef.current
5351
+ );
5352
+ } else if (
5353
+ !autoSizedToWindow &&
5354
+ userPreferredLimit &&
5355
+ userPreferredLimit > 0
5356
+ ) {
5357
+ debugLog(
5358
+ '[DataGrid] Fixed-height grid, respecting profile preference:',
5359
+ userPreferredLimit
5360
+ );
5361
+ } else if (!autoSizedToWindow && ajaxSetting?.take >= 35) {
5229
5362
  setLimit(ajaxSetting.take);
5230
5363
  } else {
5231
- // Divide by the row height actually in force. This was
5232
- // hardcoded to 32 even in tablet mode, so tablet pages asked
5233
- // for far more rows than could ever fit on screen.
5234
- const calculateRows = (height) =>
5235
- Math.floor((height - 100) / grid.rowUnit);
5236
-
5237
- const rowCount = calculateRows(minHeight);
5238
- setLimit(rowCount);
5364
+ // Prefer the chrome the grid is really wearing — the column
5365
+ // header, the filter row if this page has one, and the pager
5366
+ // toolbar all live inside `minHeight`, and they differ by
5367
+ // density and by config. GRID_CHROME is the fallback for the
5368
+ // first pass, before the grid exists to be measured.
5369
+ const chrome = measureGridChrome() ?? GRID_CHROME;
5370
+
5371
+ setLimit(rowsForHeight(minHeight, chrome, grid.rowUnit));
5239
5372
  }
5240
- }, [dataCount, gridHeight, window.innerHeight, grid, userProfile?.rows_per_page, userProfile?.settings?.rows_per_page]);
5373
+ }, [
5374
+ dataCount,
5375
+ gridHeight,
5376
+ resolvedHeight,
5377
+ window.innerHeight,
5378
+ grid,
5379
+ ajaxSetting?.take,
5380
+ userProfile?.rows_per_page,
5381
+ userProfile?.settings?.rows_per_page,
5382
+ ]);
5241
5383
 
5242
5384
  useEffect(() => {
5243
5385
  const debouncedHandleResize = debounce(() => {
@@ -5393,8 +5535,9 @@ const DataGrid = forwardRef(
5393
5535
  </div>
5394
5536
  )}
5395
5537
 
5396
- {/* DataGrid container with its own border */}
5397
- <div className={styles.dataGridContainer}>
5538
+ {/* DataGrid container with its own border; its top edge is
5539
+ what the available height is measured from. */}
5540
+ <div className={styles.dataGridContainer} ref={containerRef}>
5398
5541
 
5399
5542
  {limit && limit > 0 ? (
5400
5543
  <ReactDataGrid
@@ -5933,7 +6076,7 @@ const DataGrid = forwardRef(
5933
6076
  onFilterValueChange={(fv) => {
5934
6077
  handleFilterChange(fv, filterValue);
5935
6078
  }}
5936
- onLimitChange={setLimit}
6079
+ onLimitChange={handleLimitChange}
5937
6080
  onSelectionChange={handleSelectionChange}
5938
6081
  onSortInfoChange={handleSort}
5939
6082
  onRenderRow={onRenderRow}
@@ -4,6 +4,9 @@ import { trackPromise } from 'react-promise-tracker';
4
4
  import { toast } from 'react-toastify';
5
5
  import parse from 'html-react-parser';
6
6
 
7
+ // import.meta.env is Vite-only; this reads it safely under any bundler.
8
+ import { readBuildEnv } from './utils/buildEnv';
9
+
7
10
  // Create a singleton QueryClient instance for the entire visns-components ecosystem
8
11
  const createQueryClient = () => {
9
12
  return new QueryClient({
@@ -165,7 +168,8 @@ const originalCustomFetch = (
165
168
  method,
166
169
  formData,
167
170
  successCallback = null,
168
- errorCallback = null
171
+ errorCallback = null,
172
+ options = {}
169
173
  ) => {
170
174
  const baseUrl = '';
171
175
 
@@ -191,7 +195,7 @@ const originalCustomFetch = (
191
195
  headers['X-XSRF-TOKEN'] = csrfToken;
192
196
  }
193
197
  const MAX_PAYLOAD_SIZE_MB =
194
- parseFloat(import.meta.env.VITE_MAX_PAYLOAD_SIZE_MB) || 4.5;
198
+ parseFloat(readBuildEnv('VITE_MAX_PAYLOAD_SIZE_MB')) || 4.5;
195
199
  const MAX_PAYLOAD_SIZE = MAX_PAYLOAD_SIZE_MB * 1024 * 1024; // Convert MB to bytes
196
200
 
197
201
  // Estimate headers size
@@ -304,6 +308,40 @@ const originalCustomFetch = (
304
308
  resolve(responseObj);
305
309
  })
306
310
  .catch((error) => {
311
+ /*
312
+ * `options.silentStatuses` — an opt-in list of HTTP statuses
313
+ * that are part of a flow rather than a failure, and so must
314
+ * not be announced.
315
+ *
316
+ * The vault's reveal endpoint answers 423 to mean "confirm
317
+ * your password first", which the caller handles by drawing a
318
+ * confirm panel. Without this, that perfectly ordinary step
319
+ * either toasts an error at the user or forces the caller to
320
+ * pass an errorCallback that can only see a message string —
321
+ * never the status it needs to branch on.
322
+ *
323
+ * A listed status skips the toast AND the errorCallback, and
324
+ * rejects with the status and parsed body hung directly off
325
+ * the error so `catch (e) { e.status; e.data }` works. Opt-in,
326
+ * so every existing caller behaves exactly as before.
327
+ */
328
+ const silentStatuses = Array.isArray(options.silentStatuses)
329
+ ? options.silentStatuses
330
+ : [];
331
+ const responseStatus = error.response
332
+ ? error.response.status
333
+ : undefined;
334
+
335
+ if (
336
+ responseStatus !== undefined &&
337
+ silentStatuses.includes(responseStatus)
338
+ ) {
339
+ error.status = responseStatus;
340
+ error.data = error.response.data;
341
+ reject(error);
342
+ return;
343
+ }
344
+
307
345
  let errorMessage = '';
308
346
 
309
347
  if (error.response && error.response.data) {
@@ -323,6 +361,21 @@ const originalCustomFetch = (
323
361
  errorMessage = data.message;
324
362
  }
325
363
  }
364
+
365
+ // Last resort, and only when nothing above found anything:
366
+ // this library's own backends answer a rejected auth
367
+ // attempt with `{error: '…'}` and no `message` at all, and
368
+ // on a non-200 that body used to be dropped entirely — the
369
+ // caller got an empty toast and no idea why it failed.
370
+ // Ordered after `message` so nothing that already surfaced
371
+ // changes.
372
+ if (
373
+ !errorMessage &&
374
+ typeof data.error === 'string' &&
375
+ data.error !== ''
376
+ ) {
377
+ errorMessage = data.error;
378
+ }
326
379
  } else {
327
380
  errorMessage = error.message;
328
381
  }
@@ -386,6 +439,15 @@ const CustomFetch = (
386
439
  } else if (data.message) {
387
440
  errorMessage = data.message;
388
441
  }
442
+ // Same last-resort `{error}` fallback as the
443
+ // uncached path above.
444
+ if (
445
+ !errorMessage &&
446
+ typeof data.error === 'string' &&
447
+ data.error !== ''
448
+ ) {
449
+ errorMessage = data.error;
450
+ }
389
451
  } else {
390
452
  errorMessage = error.message;
391
453
  }
@@ -408,6 +470,14 @@ const CustomFetch = (
408
470
  errorMessage = data.message;
409
471
  }
410
472
  }
473
+ // Same last-resort `{error}` fallback as above.
474
+ if (
475
+ !errorMessage &&
476
+ typeof data.error === 'string' &&
477
+ data.error !== ''
478
+ ) {
479
+ errorMessage = data.error;
480
+ }
411
481
  } else {
412
482
  errorMessage = error.message;
413
483
  }
@@ -424,7 +494,14 @@ const CustomFetch = (
424
494
  } else {
425
495
  // Use original implementation for non-cacheable requests (mutations)
426
496
  const result = trackPromise(
427
- originalCustomFetch(url, method, formData, successCallback, errorCallback)
497
+ originalCustomFetch(
498
+ url,
499
+ method,
500
+ formData,
501
+ successCallback,
502
+ errorCallback,
503
+ options
504
+ )
428
505
  );
429
506
 
430
507
  // Auto-invalidate related caches after mutations
@@ -23,6 +23,7 @@ import SumEqualsSummary, {
23
23
  sumEqualsHintFor,
24
24
  } from './SumEqualsSummary';
25
25
  import Table from './DataGrid';
26
+ import { SECONDARY_GRID_HEIGHT } from './utils/useDensity';
26
27
 
27
28
  import styles from './styles/Form.module.scss';
28
29
 
@@ -3195,6 +3196,14 @@ function Form({
3195
3196
  mapbox={mapbox}
3196
3197
  paramValue={paramValue}
3197
3198
  settings={item.settings}
3199
+ /* A form is almost always inside
3200
+ a modal, so this grid must not
3201
+ take the room between itself and
3202
+ the bottom of the window — that
3203
+ is measured from a box that is
3204
+ already centred, and pushes the
3205
+ pager below the fold. */
3206
+ gridHeight={SECONDARY_GRID_HEIGHT}
3198
3207
  style={
3199
3208
  userProfile?.settings?.style ||
3200
3209
  {}