@visns-studio/visns-components 6.6.4 → 6.15.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 (97) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +171 -37
  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/utils/buildEnv.js +67 -0
  78. package/src/components/utils/displayValue.js +94 -0
  79. package/src/components/utils/useDensity.js +345 -9
  80. package/src/components/vault/OtpChip.jsx +208 -0
  81. package/src/components/vault/PasswordGenerator.jsx +145 -0
  82. package/src/components/vault/QrScanner.jsx +326 -0
  83. package/src/components/vault/VaultAccessLog.jsx +225 -0
  84. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  85. package/src/components/vault/VaultEntryForm.jsx +740 -0
  86. package/src/components/vault/VaultManager.jsx +1000 -0
  87. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  88. package/src/components/vault/qrDecode.js +257 -0
  89. package/src/components/vault/useDebouncedValue.js +22 -0
  90. package/src/components/vault/useVaultReveal.js +160 -0
  91. package/src/components/vault/vaultClipboard.js +33 -0
  92. package/src/components/vault/vaultEndpoints.js +40 -0
  93. package/src/components/vault/vaultFit.js +116 -0
  94. package/src/components/vault/vaultHelpers.js +547 -0
  95. package/src/components/vault/vaultNavigation.jsx +58 -0
  96. package/src/components/vault/vaultOtp.js +105 -0
  97. package/src/index.js +233 -1
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Reading a QR code, from whichever surface the user has to hand.
3
+ *
4
+ * Two entry points, both of which come back with the decoded *text* and leave
5
+ * every judgement about what that text means to the caller — `parseOtpAuthUri`
6
+ * in vaultHelpers.js is the thing that decides whether a QR was an
7
+ * authenticator setup code, and it already produces the sentences the user
8
+ * reads.
9
+ *
10
+ * Nothing here is imported at module load. `jsqr` is ~360 KB of decoder that
11
+ * only matters once someone actually points a camera at something, so it is
12
+ * pulled in with `await import('jsqr')` on first use: a consumer that never
13
+ * opens the vault form never ships it. The promise is cached, so the second
14
+ * frame does not pay for the import again.
15
+ *
16
+ * Where the browser has `BarcodeDetector` — Chrome and Edge on desktop and
17
+ * Android — that is tried first: it is the platform's own decoder, it is
18
+ * faster than anything in JavaScript, and it costs no download at all. Safari
19
+ * (so every iPad) does not have it, which is exactly why the jsQR fallback is
20
+ * not optional.
21
+ */
22
+
23
+ /**
24
+ * The long edge, in pixels, an image is scaled down to before decoding.
25
+ *
26
+ * A modern phone screenshot is 3-4x this, and jsQR's cost is linear in pixel
27
+ * count while its accuracy is not — a 4032px screenshot takes seconds to scan
28
+ * and finds nothing a 1280px one misses. Video frames are already smaller
29
+ * than this and pass through untouched.
30
+ */
31
+ export const MAX_DECODE_EDGE = 1280;
32
+
33
+ let jsQrPromise = null;
34
+
35
+ /** The lazy `jsqr` import, resolved once and reused. */
36
+ const loadJsQr = () => {
37
+ if (!jsQrPromise) {
38
+ jsQrPromise = import('jsqr').then((module) => {
39
+ // The package is a UMD bundle, so what a namespace import puts on
40
+ // `default` depends on the loader. Take the first thing that is
41
+ // actually callable rather than guessing.
42
+ const candidates = [module?.default?.default, module?.default, module?.jsQR, module];
43
+
44
+ const found = candidates.find((one) => typeof one === 'function');
45
+
46
+ if (!found) throw new Error('jsQR did not load.');
47
+
48
+ return found;
49
+ });
50
+ }
51
+
52
+ return jsQrPromise;
53
+ };
54
+
55
+ /**
56
+ * Decode raw pixels.
57
+ *
58
+ * Takes anything shaped like an `ImageData` — `{ data, width, height }` — so
59
+ * this is testable in Node against a PNG decoded by pngjs, with no DOM at all
60
+ * (tests/vaultQr.test.mjs).
61
+ *
62
+ * Two passes. The first assumes dark-on-light, which is what a QR code
63
+ * printed by any provider actually is, and is roughly twice as fast. Only if
64
+ * that finds nothing is the inverted reading tried as well, which catches the
65
+ * screenshots taken in a dark-mode authenticator app.
66
+ *
67
+ * Returns the decoded text, or `null` when there is no QR code in the pixels.
68
+ * Async because the decoder itself is loaded on demand — see the file header.
69
+ */
70
+ export const decodeImageData = async (imageData) => {
71
+ if (!imageData?.data || !imageData.width || !imageData.height) return null;
72
+
73
+ const jsQR = await loadJsQr();
74
+ const { data, width, height } = imageData;
75
+
76
+ for (const inversionAttempts of ['dontInvert', 'attemptBoth']) {
77
+ let result = null;
78
+
79
+ try {
80
+ result = jsQR(data, width, height, { inversionAttempts });
81
+ } catch {
82
+ // A malformed buffer is a "no code here", not a crash: this runs
83
+ // on every video frame.
84
+ return null;
85
+ }
86
+
87
+ const text = result?.data;
88
+
89
+ if (typeof text === 'string' && text !== '') return text;
90
+ }
91
+
92
+ return null;
93
+ };
94
+
95
+ /** The natural pixel size of a video, image or canvas. */
96
+ const sourceSize = (source) => {
97
+ const width =
98
+ source?.videoWidth || source?.naturalWidth || source?.width || 0;
99
+ const height =
100
+ source?.videoHeight || source?.naturalHeight || source?.height || 0;
101
+
102
+ return { width, height };
103
+ };
104
+
105
+ let scratchCanvas = null;
106
+
107
+ /**
108
+ * One canvas, reused.
109
+ *
110
+ * The scanner draws four frames a second for as long as it is open; a fresh
111
+ * canvas per frame is garbage the GC then has to chase while the same loop is
112
+ * trying to hold 250ms.
113
+ */
114
+ const getScratchCanvas = (width, height) => {
115
+ if (typeof document === 'undefined') return null;
116
+
117
+ if (!scratchCanvas) scratchCanvas = document.createElement('canvas');
118
+
119
+ if (scratchCanvas.width !== width) scratchCanvas.width = width;
120
+ if (scratchCanvas.height !== height) scratchCanvas.height = height;
121
+
122
+ return scratchCanvas;
123
+ };
124
+
125
+ /** Has this browser got the platform decoder? */
126
+ const hasBarcodeDetector = () =>
127
+ typeof window !== 'undefined' && 'BarcodeDetector' in window;
128
+
129
+ let barcodeDetector = null;
130
+ let barcodeDetectorFailed = false;
131
+
132
+ const getBarcodeDetector = () => {
133
+ if (barcodeDetectorFailed || !hasBarcodeDetector()) return null;
134
+
135
+ if (!barcodeDetector) {
136
+ try {
137
+ // eslint-disable-next-line no-undef
138
+ barcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] });
139
+ } catch {
140
+ // Present but without qr_code support, which is allowed by the
141
+ // spec. Never ask again.
142
+ barcodeDetectorFailed = true;
143
+
144
+ return null;
145
+ }
146
+ }
147
+
148
+ return barcodeDetector;
149
+ };
150
+
151
+ /**
152
+ * Decode a `<video>` frame, an `<img>`, or a `<canvas>`.
153
+ *
154
+ * `BarcodeDetector` first where it exists, then jsQR over the pixels drawn to
155
+ * a canvas — downscaled to `MAX_DECODE_EDGE` on the long edge, which is what
156
+ * keeps a phone screenshot from taking several seconds.
157
+ *
158
+ * Returns the decoded text, or `null`. Never throws: the caller is a render
159
+ * loop or a file picker, and neither has anywhere useful to put an exception.
160
+ */
161
+ export const decodeFromSource = async (source) => {
162
+ if (!source) return null;
163
+
164
+ const detector = getBarcodeDetector();
165
+
166
+ if (detector) {
167
+ try {
168
+ const codes = await detector.detect(source);
169
+ const text = codes?.[0]?.rawValue;
170
+
171
+ if (typeof text === 'string' && text !== '') return text;
172
+ } catch {
173
+ // Some builds throw on a video that has not painted a frame yet,
174
+ // and Firefox throws for the whole API. Fall through to jsQR
175
+ // rather than treating it as "no code".
176
+ }
177
+ }
178
+
179
+ const { width, height } = sourceSize(source);
180
+
181
+ if (!width || !height) return null;
182
+
183
+ const scale = Math.min(1, MAX_DECODE_EDGE / Math.max(width, height));
184
+ const targetWidth = Math.max(1, Math.round(width * scale));
185
+ const targetHeight = Math.max(1, Math.round(height * scale));
186
+
187
+ const canvas = getScratchCanvas(targetWidth, targetHeight);
188
+
189
+ if (!canvas) return null;
190
+
191
+ // `willReadFrequently` is the difference between a GPU-backed canvas that
192
+ // stalls on every `getImageData` and a software one that does not.
193
+ const context = canvas.getContext('2d', { willReadFrequently: true });
194
+
195
+ if (!context) return null;
196
+
197
+ try {
198
+ context.drawImage(source, 0, 0, targetWidth, targetHeight);
199
+
200
+ return await decodeImageData(
201
+ context.getImageData(0, 0, targetWidth, targetHeight)
202
+ );
203
+ } catch {
204
+ // A cross-origin image taints the canvas and `getImageData` throws.
205
+ return null;
206
+ }
207
+ };
208
+
209
+ /**
210
+ * Decode a `File` or `Blob` — the upload, paste and drop paths.
211
+ *
212
+ * Wraps the blob in an `<img>` because that is the one decoder path available
213
+ * everywhere; `createImageBitmap` would be tidier but Safari's support for it
214
+ * with SVG and some PNGs is still uneven, and this runs on an iPad.
215
+ */
216
+ export const decodeFromBlob = async (blob) => {
217
+ if (!blob || typeof document === 'undefined' || typeof URL === 'undefined') {
218
+ return null;
219
+ }
220
+
221
+ const url = URL.createObjectURL(blob);
222
+
223
+ try {
224
+ const image = await new Promise((resolve, reject) => {
225
+ const element = new window.Image();
226
+
227
+ element.onload = () => resolve(element);
228
+ element.onerror = () => reject(new Error('not an image'));
229
+ element.src = url;
230
+ });
231
+
232
+ return await decodeFromSource(image);
233
+ } catch {
234
+ return null;
235
+ } finally {
236
+ URL.revokeObjectURL(url);
237
+ }
238
+ };
239
+
240
+ /** The first image on a paste or drop, or null if there wasn't one. */
241
+ export const imageFromTransfer = (transfer) => {
242
+ if (!transfer) return null;
243
+
244
+ const items = transfer.items ? Array.from(transfer.items) : [];
245
+
246
+ for (const item of items) {
247
+ if (item.kind === 'file' && String(item.type).startsWith('image/')) {
248
+ const file = item.getAsFile();
249
+
250
+ if (file) return file;
251
+ }
252
+ }
253
+
254
+ const files = transfer.files ? Array.from(transfer.files) : [];
255
+
256
+ return files.find((file) => String(file.type).startsWith('image/')) || null;
257
+ };
@@ -0,0 +1,22 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * A value that lags behind by `delay` ms once it stops changing.
5
+ *
6
+ * Used to keep a keystroke from becoming a request. The same shape as the
7
+ * debounce inside QuickAction, kept here so the vault's two search boxes
8
+ * share one implementation.
9
+ */
10
+ const useDebouncedValue = (value, delay = 250) => {
11
+ const [debounced, setDebounced] = useState(value);
12
+
13
+ useEffect(() => {
14
+ const handle = setTimeout(() => setDebounced(value), delay);
15
+
16
+ return () => clearTimeout(handle);
17
+ }, [value, delay]);
18
+
19
+ return debounced;
20
+ };
21
+
22
+ export default useDebouncedValue;
@@ -0,0 +1,160 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+
3
+ import CustomFetch from '../Fetch';
4
+ import { resolveVaultEndpoints } from './vaultEndpoints';
5
+
6
+ /**
7
+ * The reveal flow, in one place.
8
+ *
9
+ * Revealing a stored password can come back 423 — "confirm your CRM password
10
+ * first" — which is a step in the flow, not a failure: the backend wants proof
11
+ * the person at the keyboard is the person who logged in, and grants a short
12
+ * window once they give it. Both surfaces that reveal a password (the header
13
+ * quick search and the manager table) need identical behaviour, so it lives
14
+ * here rather than twice.
15
+ *
16
+ * `reveal(id)` returns a promise for the password. When a confirmation is
17
+ * needed that promise does not settle: `confirmRequired` flips true, the
18
+ * caller draws the panel, and a successful `confirm()` retries the original
19
+ * reveal and settles the promise the caller is already awaiting. Nothing
20
+ * upstream has to know a detour happened.
21
+ *
22
+ * The 423 is passed to CustomFetch as a silent status so it does not toast an
23
+ * error at somebody who has done nothing wrong; every other status keeps the
24
+ * library's normal error handling.
25
+ */
26
+ const useVaultReveal = (endpoints) => {
27
+ // Memoised, or every render produces a fresh routes object, which changes
28
+ // the identity of `request` and `reveal`, which re-runs every effect and
29
+ // callback downstream that depends on them.
30
+ const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
31
+
32
+ const [confirmRequired, setConfirmRequired] = useState(false);
33
+ const [confirmMessage, setConfirmMessage] = useState('');
34
+ const [confirmError, setConfirmError] = useState('');
35
+ const [confirmPending, setConfirmPending] = useState(false);
36
+ const [revealingId, setRevealingId] = useState(null);
37
+
38
+ // The reveal that was interrupted, held until the confirmation resolves.
39
+ const pendingRef = useRef(null);
40
+
41
+ const request = useCallback(
42
+ (entryId) =>
43
+ new Promise((resolve, reject) => {
44
+ setRevealingId(entryId);
45
+
46
+ CustomFetch(
47
+ routes.reveal(entryId),
48
+ 'POST',
49
+ {},
50
+ (result) => {
51
+ setRevealingId(null);
52
+ resolve(result?.password ?? '');
53
+ },
54
+ null,
55
+ // 423 is the confirm-first handshake, handled below.
56
+ { silentStatuses: [423] }
57
+ ).catch((error) => {
58
+ setRevealingId(null);
59
+ reject(error);
60
+ });
61
+ }),
62
+ [routes]
63
+ );
64
+
65
+ const reveal = useCallback(
66
+ (entryId) =>
67
+ request(entryId).catch((error) => {
68
+ if (error?.status !== 423) throw error;
69
+
70
+ // Park the caller's promise and ask for the password. The
71
+ // caller keeps awaiting; `confirm` settles this for them.
72
+ return new Promise((resolve, reject) => {
73
+ pendingRef.current = { entryId, resolve, reject };
74
+ setConfirmMessage(
75
+ error.data?.message ||
76
+ 'Confirm your CRM password to reveal this.'
77
+ );
78
+ setConfirmError('');
79
+ setConfirmRequired(true);
80
+ });
81
+ }),
82
+ [request]
83
+ );
84
+
85
+ const cancelConfirm = useCallback(() => {
86
+ const pending = pendingRef.current;
87
+
88
+ pendingRef.current = null;
89
+ setConfirmRequired(false);
90
+ setConfirmError('');
91
+ setConfirmMessage('');
92
+ setConfirmPending(false);
93
+
94
+ // A cancelled reveal is a cancellation, not an error: it carries a
95
+ // flag so the caller can drop it without drawing anything.
96
+ if (pending) {
97
+ const cancelled = new Error('Reveal cancelled.');
98
+ cancelled.cancelled = true;
99
+ pending.reject(cancelled);
100
+ }
101
+ }, []);
102
+
103
+ const confirm = useCallback(
104
+ (password) => {
105
+ if (confirmPending) return;
106
+
107
+ const pending = pendingRef.current;
108
+
109
+ setConfirmPending(true);
110
+ setConfirmError('');
111
+
112
+ CustomFetch(
113
+ routes.confirm,
114
+ 'POST',
115
+ { password },
116
+ () => {
117
+ setConfirmPending(false);
118
+ setConfirmRequired(false);
119
+ setConfirmMessage('');
120
+ pendingRef.current = null;
121
+
122
+ if (!pending) return;
123
+
124
+ // Straight back to what the user actually asked for.
125
+ request(pending.entryId).then(pending.resolve, pending.reject);
126
+ },
127
+ null,
128
+ // Both of these are answers, not errors: a wrong password and
129
+ // a rate limit each get their own line in the panel.
130
+ { silentStatuses: [422, 429] }
131
+ ).catch((error) => {
132
+ setConfirmPending(false);
133
+
134
+ if (error?.status === 422) {
135
+ setConfirmError('That password is not correct.');
136
+ } else if (error?.status === 429) {
137
+ setConfirmError('Too many attempts — wait a minute.');
138
+ } else {
139
+ // Anything else was already toasted by CustomFetch; the
140
+ // panel stays open so the attempt is not simply lost.
141
+ setConfirmError('That did not work. Try again.');
142
+ }
143
+ });
144
+ },
145
+ [confirmPending, request, routes]
146
+ );
147
+
148
+ return {
149
+ reveal,
150
+ revealingId,
151
+ confirmRequired,
152
+ confirmMessage,
153
+ confirmError,
154
+ confirmPending,
155
+ confirm,
156
+ cancelConfirm,
157
+ };
158
+ };
159
+
160
+ export default useVaultReveal;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Copying, and what to do when the browser will not.
3
+ *
4
+ * `navigator.clipboard` needs a secure context, so it is simply absent on any
5
+ * deployment still served over plain http — and a vault whose copy button
6
+ * does nothing, silently, is worse than one that never had a copy button. The
7
+ * caller uses the `false` return to fall back to showing the value in a
8
+ * read-only input the user can select by hand.
9
+ */
10
+
11
+ /** True when the value reached the clipboard. Never throws. */
12
+ export const copyText = async (value) => {
13
+ const text = String(value ?? '');
14
+
15
+ if (text === '') return false;
16
+
17
+ const clipboard = globalThis.navigator?.clipboard;
18
+
19
+ if (!clipboard || typeof clipboard.writeText !== 'function') return false;
20
+
21
+ try {
22
+ await clipboard.writeText(text);
23
+ return true;
24
+ } catch {
25
+ // Denied permission, or a document that was not focused at the moment
26
+ // of the write. Either way the caller shows the value instead.
27
+ return false;
28
+ }
29
+ };
30
+
31
+ /** Whether a copy button can work at all, for deciding what to render. */
32
+ export const canCopy = () =>
33
+ typeof globalThis.navigator?.clipboard?.writeText === 'function';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The URLs the vault talks to.
3
+ *
4
+ * Every vault component takes an `endpoints` object rather than a base string,
5
+ * so a consumer can move one route without reproducing the other eleven. The
6
+ * defaults are the CRM's routes under `/ajax/vault`; `makeVaultEndpoints` is
7
+ * there for the common case of moving all of them at once.
8
+ *
9
+ * Pure and React-free so `node --test` can import it.
10
+ */
11
+
12
+ export const makeVaultEndpoints = (base = '/ajax/vault') => ({
13
+ list: base,
14
+ show: (id) => `${base}/${id}`,
15
+ create: base,
16
+ update: (id) => `${base}/${id}`,
17
+ destroy: (id) => `${base}/${id}`,
18
+ restore: (id) => `${base}/${id}/restore`,
19
+ confirm: `${base}/confirm-password`,
20
+ reveal: (id) => `${base}/${id}/reveal`,
21
+ otp: (id) => `${base}/${id}/otp`,
22
+ logCopy: (id) => `${base}/${id}/log`,
23
+ entryLog: (id) => `${base}/${id}/log`,
24
+ globalLog: `${base}/log`,
25
+ });
26
+
27
+ export const DEFAULT_ENDPOINTS = makeVaultEndpoints();
28
+
29
+ /**
30
+ * Merge a partial `endpoints` prop over the defaults.
31
+ *
32
+ * A consumer overriding one route must not lose the other eleven, and passing
33
+ * nothing at all must land exactly on DEFAULT_ENDPOINTS.
34
+ */
35
+ export const resolveVaultEndpoints = (endpoints) => ({
36
+ ...DEFAULT_ENDPOINTS,
37
+ ...(endpoints || {}),
38
+ });
39
+
40
+ export default DEFAULT_ENDPOINTS;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * How many rows fit, and which page to land on when that number changes.
3
+ *
4
+ * The vault's list is not a document that scrolls: the user asked to see the
5
+ * entries *and* the pager without moving anything, so the page size follows
6
+ * the window rather than the other way round. That turns "25 entries" into a
7
+ * measurement, and a measurement wants a pure function it can be tested
8
+ * against — the components only feed it numbers.
9
+ *
10
+ * Pure and React-free so `node --test` can import it.
11
+ */
12
+
13
+ /**
14
+ * Floors and ceilings on the computed page size.
15
+ *
16
+ * MIN keeps a short window (or a mis-measurement) from asking the server for
17
+ * one row at a time; MAX is VaultController::MAX_PER_PAGE, which silently
18
+ * clamps anything larger — asking for more would quietly return fewer than the
19
+ * caller then tries to draw.
20
+ */
21
+ export const MIN_ROWS = 5;
22
+ export const MAX_ROWS = 100;
23
+
24
+ /**
25
+ * The number of rows that fit in the space left below the list's top edge.
26
+ *
27
+ * Every argument is px. `available` is the distance from the top of the list
28
+ * container to the bottom of the useful area (see `useAvailableHeight`), out
29
+ * of which the pager and any fixed furniture inside the container — a header
30
+ * row — must be paid before rows can be counted.
31
+ *
32
+ * Returns `null` rather than a guess when it cannot measure: no height yet, or
33
+ * a row height of zero because nothing has rendered. A caller that gets `null`
34
+ * keeps whatever page size it is already using, which is why the list does not
35
+ * flash a five-row page on first paint.
36
+ *
37
+ * @param {object} options
38
+ * @param {number} [options.available] px from the container's top to the
39
+ * bottom of the usable area.
40
+ * @param {number} [options.pagerHeight] px the pager occupies, gap included.
41
+ * @param {number} [options.rowHeight] px one row occupies.
42
+ * @param {number} [options.chrome] px of fixed furniture inside the
43
+ * container (the header row).
44
+ * @param {number} [options.min] floor, default MIN_ROWS.
45
+ * @param {number} [options.max] ceiling, default MAX_ROWS.
46
+ * @returns {number|null}
47
+ */
48
+ export const fitRowsToHeight = ({
49
+ available,
50
+ pagerHeight = 0,
51
+ rowHeight,
52
+ chrome = 0,
53
+ min = MIN_ROWS,
54
+ max = MAX_ROWS,
55
+ } = {}) => {
56
+ // `Number(null)` is 0, and 0 is a perfectly finite number of pixels — so
57
+ // an unmeasured height would otherwise read as "no room" and clamp to the
58
+ // floor rather than saying it does not know.
59
+ const height = available == null || available === '' ? NaN : Number(available);
60
+ const row = rowHeight == null || rowHeight === '' ? NaN : Number(rowHeight);
61
+
62
+ if (!Number.isFinite(height) || !Number.isFinite(row) || row <= 0) {
63
+ return null;
64
+ }
65
+
66
+ const pager = Number.isFinite(Number(pagerHeight)) ? Number(pagerHeight) : 0;
67
+ const furniture = Number.isFinite(Number(chrome)) ? Number(chrome) : 0;
68
+
69
+ const forRows = height - pager - furniture;
70
+
71
+ // A low floor and a high ceiling the wrong way round would otherwise make
72
+ // the clamp depend on which one is applied first.
73
+ const low = Number.isFinite(Number(min)) ? Math.max(1, Number(min)) : MIN_ROWS;
74
+ const high = Number.isFinite(Number(max)) ? Number(max) : MAX_ROWS;
75
+ const ceiling = Math.max(low, high);
76
+
77
+ // Floor, not round: half a row showing is the scrollbar this exists to
78
+ // avoid.
79
+ const fits = Math.floor(forRows / row);
80
+
81
+ return Math.min(ceiling, Math.max(low, fits));
82
+ };
83
+
84
+ /**
85
+ * The page that still contains a given entry once the page size changes.
86
+ *
87
+ * Resizing the window must not teleport the reader: the entry at the top of
88
+ * the page they were on stays on screen, which means recomputing the page
89
+ * number from that entry's index in the whole result set.
90
+ *
91
+ * @param {number} firstIndex zero-based index of the first entry on the page
92
+ * the user is looking at.
93
+ * @param {number} perPage the new page size.
94
+ * @returns {number} a 1-based page number, never below 1.
95
+ */
96
+ export const pageForIndex = (firstIndex, perPage) => {
97
+ const index = Number(firstIndex);
98
+ const size = Number(perPage);
99
+
100
+ if (!Number.isFinite(size) || size <= 0) return 1;
101
+ if (!Number.isFinite(index) || index <= 0) return 1;
102
+
103
+ return Math.floor(index / size) + 1;
104
+ };
105
+
106
+ /** The zero-based index of the first entry on `page` at `perPage` a page. */
107
+ export const firstIndexOfPage = (page, perPage) => {
108
+ const number = Number(page);
109
+ const size = Number(perPage);
110
+
111
+ if (!Number.isFinite(number) || !Number.isFinite(size)) return 0;
112
+
113
+ return Math.max(0, (Math.max(1, number) - 1) * Math.max(1, size));
114
+ };
115
+
116
+ export default fitRowsToHeight;