@visns-studio/visns-components 6.20.1 → 6.23.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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/components/DataGrid.jsx +15 -2
  3. package/src/components/Navigation.jsx +10 -1
  4. package/src/components/columns/SelectFilterEditor.jsx +372 -0
  5. package/src/components/controls/DataGridSearch.jsx +6 -1
  6. package/src/components/layout/AnchoredMenu.jsx +131 -0
  7. package/src/components/layout/BandedList.jsx +158 -0
  8. package/src/components/layout/FactsBand.jsx +150 -0
  9. package/src/components/layout/PageHeader.jsx +90 -0
  10. package/src/components/layout/ScrollRegion.jsx +53 -0
  11. package/src/components/layout/StatStrip.jsx +170 -0
  12. package/src/components/layout/useContainedHeight.js +82 -0
  13. package/src/components/navigation/TabStrip.jsx +156 -0
  14. package/src/components/phone/ZoomPhoneBadge.jsx +625 -0
  15. package/src/components/phone/phonePresenceEndpoints.js +34 -0
  16. package/src/components/phone/phonePresenceHelpers.js +298 -0
  17. package/src/components/phone/useZoomPhoneLive.js +165 -0
  18. package/src/components/sms/SmsClientConversations.jsx +8 -0
  19. package/src/components/sms/SmsInbox.jsx +11 -0
  20. package/src/components/sms/SmsThreadPanel.jsx +258 -29
  21. package/src/components/sms/smsHelpers.js +95 -0
  22. package/src/components/styles/AnchoredMenu.module.scss +43 -0
  23. package/src/components/styles/BandedList.module.scss +278 -0
  24. package/src/components/styles/DataGrid.module.scss +10 -0
  25. package/src/components/styles/FactsBand.module.scss +108 -0
  26. package/src/components/styles/GenericDetail.module.scss +12 -2
  27. package/src/components/styles/PageHeader.module.scss +98 -0
  28. package/src/components/styles/ScrollRegion.module.scss +20 -0
  29. package/src/components/styles/Sms.module.scss +131 -0
  30. package/src/components/styles/StatStrip.module.scss +183 -0
  31. package/src/components/styles/TabStrip.module.scss +96 -0
  32. package/src/components/styles/Vault.module.scss +46 -0
  33. package/src/components/styles/ZoomPhone.module.scss +550 -0
  34. package/src/components/styles/_surface.scss +49 -0
  35. package/src/components/styles/global-datagrid.css +23 -0
  36. package/src/components/vault/VaultAccessLog.jsx +24 -0
  37. package/src/components/vault/VaultShareModal.jsx +126 -5
  38. package/src/components/vault/vaultShares.js +67 -3
  39. package/src/index.js +88 -2
@@ -1,6 +1,15 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { toast } from 'react-toastify';
3
- import { Check, Copy, Link2, Loader2, ShieldAlert, Trash2 } from 'lucide-react';
3
+ import {
4
+ AlertTriangle,
5
+ Check,
6
+ Copy,
7
+ Link2,
8
+ Loader2,
9
+ Mail,
10
+ ShieldAlert,
11
+ Trash2,
12
+ } from 'lucide-react';
4
13
 
5
14
  import CustomFetch from '../Fetch';
6
15
  import StandardModal from '../generic/StandardModal';
@@ -14,6 +23,8 @@ import {
14
23
  availableShareFields,
15
24
  initialShareFields,
16
25
  isShareRevocable,
26
+ shareEmailEnabled,
27
+ shareEmailIssue,
17
28
  shareFieldsLabel,
18
29
  shareRequestBody,
19
30
  shareStatus,
@@ -51,6 +62,19 @@ const STATUS_CLASS = {
51
62
  * the recipient reveals — never the seed. That is said on the checkbox, not
52
63
  * just in this comment, because it is the one thing about this dialog a person
53
64
  * could reasonably get wrong.
65
+ *
66
+ * EMAILING THE LINK is optional and changes none of the above. The server sends
67
+ * it at creation — the only moment the raw URL exists on that side — and the
68
+ * mail carries the entry's title, the link and its limits, never a value off
69
+ * the credential. Two consequences show up in this file:
70
+ *
71
+ * - The URL is still shown here, always, exactly as before. An email is a
72
+ * delivery, not a substitute for the one copy of the link, and a send that
73
+ * quietly failed must not take the credential with it — which is why the
74
+ * server answers 201 with `email_error` rather than an error status, and why
75
+ * the success panel below reports either outcome next to a copyable URL.
76
+ * - Whether the field appears at all is the server's call, read from
77
+ * `meta.email_enabled` on the share list this modal already fetches.
54
78
  */
55
79
  const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
56
80
  const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
@@ -60,6 +84,12 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
60
84
  const [fields, setFields] = useState(() => initialShareFields(entry));
61
85
  const [expiresInHours, setExpiresInHours] = useState(DEFAULT_EXPIRY_HOURS);
62
86
  const [maxViews, setMaxViews] = useState(null);
87
+ const [recipient, setRecipient] = useState('');
88
+
89
+ // Whether the server will email at all. Optimistic until the list answers:
90
+ // a field that flickers away is better than one that never appears because
91
+ // a request was slow, and the server refuses a recipient it did not offer.
92
+ const [emailEnabled, setEmailEnabled] = useState(true);
63
93
 
64
94
  const [creating, setCreating] = useState(false);
65
95
  // The one and only copy of a freshly minted URL. Held in component state
@@ -97,6 +127,9 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
97
127
  if (!mountedRef.current) return;
98
128
 
99
129
  setShares(Array.isArray(result?.data) ? result.data : []);
130
+ // The one place this modal can learn its own configuration:
131
+ // the list request it was already making.
132
+ setEmailEnabled(shareEmailEnabled(result));
100
133
  setLoading(false);
101
134
  },
102
135
  null
@@ -120,6 +153,11 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
120
153
 
121
154
  const toggle = (key) => setFields((current) => toggleShareField(current, key));
122
155
 
156
+ // A typo caught here is a sentence under the field; the same typo caught by
157
+ // the server is a 422 — and the server validates before it mints, so no
158
+ // link is lost either way. This is politeness, not a control.
159
+ const emailIssue = emailEnabled ? shareEmailIssue(recipient) : null;
160
+
123
161
  const create = useCallback(() => {
124
162
  if (!entry?.id || fields.length === 0) return;
125
163
 
@@ -128,7 +166,15 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
128
166
  CustomFetch(
129
167
  routes.createShare(entry.id),
130
168
  'POST',
131
- shareRequestBody({ fields, expiresInHours, maxViews }),
169
+ shareRequestBody({
170
+ fields,
171
+ expiresInHours,
172
+ maxViews,
173
+ // Never sent when the server said it does not email, so a
174
+ // stale tab cannot post a recipient into an installation that
175
+ // has switched it off.
176
+ recipientEmail: emailEnabled ? recipient : '',
177
+ }),
132
178
  (result) => {
133
179
  if (!mountedRef.current) return;
134
180
 
@@ -150,7 +196,16 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
150
196
  // server's "this entry has no password to share".
151
197
  if (mountedRef.current) setCreating(false);
152
198
  });
153
- }, [routes, entry, fields, expiresInHours, maxViews, load]);
199
+ }, [
200
+ routes,
201
+ entry,
202
+ fields,
203
+ expiresInHours,
204
+ maxViews,
205
+ recipient,
206
+ emailEnabled,
207
+ load,
208
+ ]);
154
209
 
155
210
  const copyUrl = useCallback(async () => {
156
211
  if (!created?.url) return;
@@ -240,6 +295,29 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
240
295
  </button>
241
296
  </div>
242
297
 
298
+ {created.emailed_to && (
299
+ <p className={styles.shareEmailed}>
300
+ <Mail size={14} strokeWidth={2.2} aria-hidden="true" />
301
+ <span>
302
+ Also emailed to{' '}
303
+ <strong>{created.emailed_to}</strong>. The
304
+ email carries this link and the entry's
305
+ title — none of the details themselves.
306
+ </span>
307
+ </p>
308
+ )}
309
+
310
+ {created.email_error && (
311
+ <p className={styles.shareEmailFailed} role="alert">
312
+ <AlertTriangle
313
+ size={14}
314
+ strokeWidth={2.2}
315
+ aria-hidden="true"
316
+ />
317
+ <span>{created.email_error}</span>
318
+ </p>
319
+ )}
320
+
243
321
  <p className={styles.shareWarning}>
244
322
  <ShieldAlert size={14} strokeWidth={2.2} aria-hidden="true" />
245
323
  <span>
@@ -259,6 +337,10 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
259
337
  onClick={() => {
260
338
  setCreated(null);
261
339
  setCopied(false);
340
+ // A second link almost never goes to the
341
+ // same person, and a left-over address is
342
+ // how one quietly does.
343
+ setRecipient('');
262
344
  }}
263
345
  >
264
346
  Create another
@@ -361,17 +443,56 @@ const VaultShareModal = ({ entry, endpoints, isOpen = true, onClose }) => {
361
443
  </p>
362
444
  </div>
363
445
 
446
+ {emailEnabled && (
447
+ <div className={styles.field}>
448
+ <label
449
+ className={styles.label}
450
+ htmlFor="vault-share-recipient"
451
+ >
452
+ Email the link to (optional)
453
+ </label>
454
+ <input
455
+ id="vault-share-recipient"
456
+ type="email"
457
+ className={styles.input}
458
+ value={recipient}
459
+ placeholder="name@example.com"
460
+ autoComplete="off"
461
+ spellCheck="false"
462
+ onChange={(e) => setRecipient(e.target.value)}
463
+ />
464
+ {emailIssue ? (
465
+ <p className={styles.fieldError}>{emailIssue}</p>
466
+ ) : (
467
+ <p className={styles.help}>
468
+ Sent when you create the link. The email
469
+ carries the link and this entry's title —
470
+ never the username, password or code. You
471
+ still get the link here to copy.
472
+ </p>
473
+ )}
474
+ </div>
475
+ )}
476
+
364
477
  <div className={styles.formActions}>
365
478
  <button
366
479
  type="button"
367
480
  className={styles.primaryButton}
368
481
  onClick={create}
369
- disabled={creating || fields.length === 0}
482
+ disabled={
483
+ creating ||
484
+ fields.length === 0 ||
485
+ Boolean(emailIssue)
486
+ }
370
487
  >
371
488
  {creating && (
372
489
  <Loader2 size={15} className={styles.spin} />
373
490
  )}
374
- {creating ? 'Creating…' : 'Create link'}
491
+ {creating
492
+ ? 'Creating…'
493
+ : recipient.trim() !== '' && emailEnabled
494
+ ? 'Create and email link'
495
+ : 'Create link'}
375
496
  </button>
376
497
  <button
377
498
  type="button"
@@ -131,17 +131,69 @@ export const toggleShareField = (fields, key) => {
131
131
  return known.filter((field) => next.includes(field));
132
132
  };
133
133
 
134
+ /**
135
+ * Whether this installation will email a link for the sender.
136
+ *
137
+ * Read off the share LIST response, which the modal already fetches when it
138
+ * opens — a second request to learn one boolean would be a round trip for
139
+ * nothing. Defaults to true when the key is absent, so a build talking to a
140
+ * server that predates the flag offers the field and lets the server refuse,
141
+ * rather than hiding a feature that is switched on.
142
+ *
143
+ * @param {object} listResponse the payload from `endpoints.shares(id)`
144
+ * @returns {boolean}
145
+ */
146
+ export const shareEmailEnabled = (listResponse) => {
147
+ const flag = listResponse?.meta?.email_enabled;
148
+
149
+ return flag === undefined || flag === null ? true : Boolean(flag);
150
+ };
151
+
152
+ /**
153
+ * What is wrong with the typed recipient address, or null.
154
+ *
155
+ * Deliberately the loosest check that still catches a typo — something, an @,
156
+ * something with a dot in it. The server validates properly and is the only
157
+ * opinion that counts; this exists so that a mistyped address is a sentence
158
+ * under the field rather than a 422 toast after the link has already been
159
+ * minted.
160
+ *
161
+ * @param {string} value
162
+ * @returns {(string|null)}
163
+ */
164
+ export const shareEmailIssue = (value) => {
165
+ const address = String(value ?? '').trim();
166
+
167
+ if (address === '') return null;
168
+
169
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)
170
+ ? null
171
+ : 'That does not look like an email address.';
172
+ };
173
+
134
174
  /**
135
175
  * The body the create endpoint wants.
136
176
  *
137
177
  * `max_views` is omitted rather than sent as null when there is no limit: the
138
178
  * server treats an absent key and a null the same way, and a body that carries
139
- * only what was chosen is easier to read in a network panel.
179
+ * only what was chosen is easier to read in a network panel. `recipient_email`
180
+ * is omitted the same way when nobody is being mailed — a create request with
181
+ * no recipient key in it is a create request that cannot possibly send mail,
182
+ * which is worth being able to read off a network panel at a glance.
183
+ *
184
+ * A recipient name is only sent alongside an address: it is the greeting on the
185
+ * email and means nothing without one.
140
186
  *
141
- * @param {{fields: Array<string>, expiresInHours: number, maxViews: (number|null)}} choice
187
+ * @param {{fields: Array<string>, expiresInHours: number, maxViews: (number|null), recipientEmail?: string, recipientName?: string}} choice
142
188
  * @returns {object}
143
189
  */
144
- export const shareRequestBody = ({ fields, expiresInHours, maxViews } = {}) => {
190
+ export const shareRequestBody = ({
191
+ fields,
192
+ expiresInHours,
193
+ maxViews,
194
+ recipientEmail,
195
+ recipientName,
196
+ } = {}) => {
145
197
  const known = SHARE_FIELDS.map((field) => field.key);
146
198
  const clean = known.filter((field) => (fields || []).includes(field));
147
199
 
@@ -160,6 +212,18 @@ export const shareRequestBody = ({ fields, expiresInHours, maxViews } = {}) => {
160
212
  body.max_views = Math.round(views);
161
213
  }
162
214
 
215
+ const address = String(recipientEmail ?? '').trim();
216
+
217
+ if (address !== '') {
218
+ body.recipient_email = address;
219
+
220
+ const name = String(recipientName ?? '').trim();
221
+
222
+ if (name !== '') {
223
+ body.recipient_name = name;
224
+ }
225
+ }
226
+
163
227
  return body;
164
228
  };
165
229
 
package/src/index.js CHANGED
@@ -178,6 +178,7 @@ import {
178
178
  clientRecipient,
179
179
  conversationCountLabel,
180
180
  describeTransport,
181
+ displayNumber,
181
182
  fillTemplate,
182
183
  groupMessagesByDay,
183
184
  initialsFor,
@@ -189,11 +190,37 @@ import {
189
190
  pickInitialThread,
190
191
  relativeTime,
191
192
  segmentCount,
193
+ smsHref,
192
194
  splitPersonName,
195
+ telHref,
193
196
  threadDisplayName,
194
197
  toE164,
195
198
  } from './components/sms/smsHelpers';
196
199
 
200
+ // Zoom Phone presence — the header phone chip. ZoomPhoneBadge mounts through
201
+ // Navigation's `renderers` slot and is the whole feature; the helpers are
202
+ // exported for a consumer that wants the same roster somewhere else (a wall
203
+ // board, say) without re-deriving what "available" means.
204
+ import ZoomPhoneBadge from './components/phone/ZoomPhoneBadge';
205
+ import useZoomPhoneLive from './components/phone/useZoomPhoneLive';
206
+ import {
207
+ DEFAULT_ENDPOINTS as DEFAULT_PHONE_PRESENCE_ENDPOINTS,
208
+ makePhonePresenceEndpoints,
209
+ resolvePhonePresenceEndpoints,
210
+ } from './components/phone/phonePresenceEndpoints';
211
+ import {
212
+ STATUS as PHONE_PRESENCE_STATUS,
213
+ applyPresenceEvent,
214
+ callDuration,
215
+ callSummary,
216
+ freshnessLabel,
217
+ onCallCount,
218
+ orderRoster,
219
+ presenceNote,
220
+ statusLabel as phoneStatusLabel,
221
+ statusTone as phoneStatusTone,
222
+ } from './components/phone/phonePresenceHelpers';
223
+
197
224
  // Desktop alerts — the browser Notification API behind three gates (support,
198
225
  // permission, the user's own switch). Used by the bell and the SMS badge; the
199
226
  // module is exported so a consumer can drive it from anywhere else too.
@@ -247,6 +274,30 @@ import SectionTypeSelector from './components/proposal/SectionTypeSelector';
247
274
  import VariableInserter from './components/proposal/VariableInserter';
248
275
  import ProposalTemplatePreview from './components/proposal/ProposalTemplatePreview';
249
276
 
277
+ /**
278
+ * Page primitives.
279
+ *
280
+ * The scaffolding a bespoke screen is built out of — the strip of figures over a
281
+ * list, the underline tabs, the urgency bands, the header row and the band of
282
+ * facts under a record's title. Every one of them was written by hand on three
283
+ * or more CRM screens first, and had already drifted between them; see
284
+ * `styles/_surface.scss` for the two-level token scheme that lets a page with an
285
+ * existing palette adopt them without redrawing itself.
286
+ */
287
+ import AnchoredMenu from './components/layout/AnchoredMenu';
288
+ import BandedList, { Band } from './components/layout/BandedList';
289
+ import FactsBand, { Fact } from './components/layout/FactsBand';
290
+ import PageHeader from './components/layout/PageHeader';
291
+ import ScrollRegion from './components/layout/ScrollRegion';
292
+ import StatStrip, { Stat } from './components/layout/StatStrip';
293
+ import useContainedHeight from './components/layout/useContainedHeight';
294
+ import TabStrip from './components/navigation/TabStrip';
295
+
296
+ // Copying, and what to do when the browser will not. Named for the vault
297
+ // because that is where it was written; nothing in it is vault-specific, and
298
+ // every screen with a copy button was otherwise restating it.
299
+ import { canCopy, copyText } from './components/vault/vaultClipboard';
300
+
250
301
  /** OAuth Components */
251
302
  import OAuthManager from './components/oauth/OAuthManager';
252
303
  import OAuthProviderCard from './components/oauth/OAuthProviderCard';
@@ -254,7 +305,6 @@ import OAuthConnectionStatus from './components/oauth/OAuthConnectionStatus';
254
305
  import OAuthIntegrationsPage from './components/oauth/OAuthIntegrationsPage';
255
306
  import DataSyncWizard from './components/oauth/DataSyncWizard';
256
307
 
257
-
258
308
  export {
259
309
  applyUsernameDomain,
260
310
  AsyncSelect,
@@ -339,6 +389,42 @@ export {
339
389
  useSmsLive,
340
390
  UniversalSearch,
341
391
  useSmsThreads,
392
+ // Phone numbers, for display and for dialling. Beside the SMS module's own
393
+ // normaliser because that is the formatter they are built on.
394
+ displayNumber,
395
+ smsHref,
396
+ telHref,
397
+ // Zoom Phone presence — the header roster.
398
+ ZoomPhoneBadge,
399
+ useZoomPhoneLive,
400
+ DEFAULT_PHONE_PRESENCE_ENDPOINTS,
401
+ makePhonePresenceEndpoints,
402
+ resolvePhonePresenceEndpoints,
403
+ PHONE_PRESENCE_STATUS,
404
+ applyPresenceEvent,
405
+ callDuration,
406
+ callSummary,
407
+ freshnessLabel,
408
+ onCallCount,
409
+ orderRoster,
410
+ presenceNote,
411
+ phoneStatusLabel,
412
+ phoneStatusTone,
413
+ // Page primitives.
414
+ AnchoredMenu,
415
+ Band,
416
+ BandedList,
417
+ Fact,
418
+ FactsBand,
419
+ PageHeader,
420
+ ScrollRegion,
421
+ Stat,
422
+ StatStrip,
423
+ TabStrip,
424
+ useContainedHeight,
425
+ // Clipboard.
426
+ canCopy,
427
+ copyText,
342
428
  CameraPlacement,
343
429
  CellWithTooltip,
344
430
  clampOtp,
@@ -458,4 +544,4 @@ export {
458
544
  VariableInserter,
459
545
  verifyContactFor,
460
546
  Verify,
461
- };
547
+ };