@visns-studio/visns-components 6.24.4 → 6.26.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 (51) hide show
  1. package/package.json +4 -2
  2. package/src/components/Autocomplete.jsx +189 -119
  3. package/src/components/DataGrid.jsx +472 -31
  4. package/src/components/Navigation.jsx +475 -51
  5. package/src/components/auth/ClientAuthFrame.jsx +5 -0
  6. package/src/components/auth/ClientAuthScreen.jsx +29 -0
  7. package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
  8. package/src/components/callQueue/CallQueuePop.jsx +713 -90
  9. package/src/components/callQueue/CallQueueSettings.jsx +308 -146
  10. package/src/components/callQueue/callPopStatus.js +236 -0
  11. package/src/components/callQueue/callQueueHelpers.js +284 -2
  12. package/src/components/columns/ColumnRenderers.jsx +3 -46
  13. package/src/components/columns/StackedRow.jsx +186 -0
  14. package/src/components/controls/DataGridSearch.jsx +110 -2
  15. package/src/components/controls/DataGridSortSheet.jsx +155 -0
  16. package/src/components/generic/GenericAuth.jsx +50 -18
  17. package/src/components/generic/GenericDashboard.jsx +20 -1
  18. package/src/components/generic/GenericDetail.jsx +446 -259
  19. package/src/components/mapboxSearchBox.js +640 -0
  20. package/src/components/navBadges.js +63 -1
  21. package/src/components/navDrawer.js +147 -0
  22. package/src/components/sms/SmsThreadPanel.jsx +34 -6
  23. package/src/components/sms/smsHelpers.js +15 -0
  24. package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
  25. package/src/components/styles/CallQueuePop.module.scss +29 -0
  26. package/src/components/styles/CallQueueSettings.module.scss +93 -0
  27. package/src/components/styles/ClientAuth.module.scss +39 -0
  28. package/src/components/styles/DataGrid.module.scss +158 -5
  29. package/src/components/styles/Field.module.scss +52 -1
  30. package/src/components/styles/Form.module.scss +82 -0
  31. package/src/components/styles/GenericClientPortal.module.scss +72 -20
  32. package/src/components/styles/GenericDashboard.module.scss +50 -0
  33. package/src/components/styles/GenericDetail.module.scss +63 -1
  34. package/src/components/styles/GenericDynamic.module.scss +23 -0
  35. package/src/components/styles/GenericFormBuilder.module.scss +11 -0
  36. package/src/components/styles/GenericIndex.module.scss +6 -1
  37. package/src/components/styles/Navigation.module.scss +460 -7
  38. package/src/components/styles/Sms.module.scss +92 -0
  39. package/src/components/styles/StackedRow.module.scss +182 -0
  40. package/src/components/styles/TicketConversation.module.scss +76 -0
  41. package/src/components/styles/Vault.module.scss +192 -0
  42. package/src/components/styles/density.css +10 -0
  43. package/src/components/styles/global-datagrid.css +163 -0
  44. package/src/components/styles/global.css +20 -0
  45. package/src/components/tickets/TicketConversation.jsx +13 -8
  46. package/src/components/utils/ConfirmDialog.js +22 -3
  47. package/src/components/utils/cardLayout.js +666 -0
  48. package/src/components/utils/contactChannels.js +130 -0
  49. package/src/components/utils/editPlacement.js +95 -0
  50. package/src/components/utils/useDensity.js +303 -7
  51. package/src/index.js +42 -0
@@ -12,6 +12,8 @@
12
12
  * badges={{ tickets: 4 }} -> "4", "Tickets, 4 open"
13
13
  * badges={{ tickets: { count: 4, label: 'unread' } }}
14
14
  * -> "4", "Tickets, 4 unread"
15
+ * badges={{ tickets: { count: 4, tone: 'danger' } }}
16
+ * -> "4", on crimson
15
17
  *
16
18
  * The object form exists because the pill itself is only a number and the
17
19
  * accessible name has to say what the number IS — a screen reader announcing
@@ -23,6 +25,46 @@
23
25
  /** The noun the accessible name falls back to. See the note above. */
24
26
  export const DEFAULT_BADGE_NOUN = 'open';
25
27
 
28
+ /**
29
+ * The severities a badge can wear, ORDERED — mildest first, worst last.
30
+ *
31
+ * The order is the point of the array rather than incidental to it:
32
+ * `navBadge()` rolls a group up by index, so a fourth step later means putting
33
+ * a name in the right place here and changing nothing else. They are named as
34
+ * severities and not as colours ('danger', never 'red') because the stylesheet
35
+ * owns what each one looks like, and a project that recolours the bar must not
36
+ * have to restate its data to do it.
37
+ *
38
+ * A tone says how BAD the number is; it never says what the number counts —
39
+ * the count and its noun already do that. It is deliberately absent from the
40
+ * accessible name: see badgeAriaLabel.
41
+ */
42
+ export const BADGE_TONES = ['ok', 'warn', 'danger'];
43
+
44
+ /**
45
+ * The tone a badge has when the consumer published none.
46
+ *
47
+ * EVERY badge carries a tone, the plain-number form included, so the pill has
48
+ * one shape to render and no caller has to guard for a missing field — the
49
+ * same bargain `display` is on, and for the same reason it had to be made
50
+ * twice before it stuck.
51
+ */
52
+ export const DEFAULT_BADGE_TONE = 'ok';
53
+
54
+ /**
55
+ * The published tone, or the default.
56
+ *
57
+ * Anything unrecognised falls back rather than passing through: the value ends
58
+ * up in a CSS-module class name, and a typo'd tone would resolve to no class
59
+ * at all — a pill with no ground and no ink, which is a worse failure than
60
+ * merely being the wrong colour.
61
+ */
62
+ const toneOf = (raw) => (BADGE_TONES.includes(raw) ? raw : DEFAULT_BADGE_TONE);
63
+
64
+ /** The worse of two tones, by their position in BADGE_TONES. */
65
+ const worseTone = (a, b) =>
66
+ BADGE_TONES.indexOf(a) >= BADGE_TONES.indexOf(b) ? a : b;
67
+
26
68
  /**
27
69
  * Above this the pill reads `99+`.
28
70
  *
@@ -65,6 +107,9 @@ const normalise = (raw) => {
65
107
  // off `count` while the pill printed an undefined `display`.
66
108
  display: badgeDisplay(whole),
67
109
  noun: (isObject && raw.label) || DEFAULT_BADGE_NOUN,
110
+ // Only the object form can carry one — a bare number is a count and
111
+ // says nothing about severity, so it is always the default.
112
+ tone: toneOf(isObject ? raw.tone : undefined),
68
113
  };
69
114
  };
70
115
 
@@ -98,7 +143,7 @@ export function badgeFor(badges, id) {
98
143
  * reader cannot act on — because the row it belongs to is not in their menu —
99
144
  * is worse than no number: they would go looking for work that is not theirs.
100
145
  *
101
- * @returns {{count: number, display: string, noun: string}|null}
146
+ * @returns {{count: number, display: string, noun: string, tone: string}|null}
102
147
  */
103
148
  export function navBadge(badges, nav) {
104
149
  if (!badges || !nav) {
@@ -128,6 +173,15 @@ export function navBadge(badges, nav) {
128
173
  // a group whose children count different nouns cannot be summarised
129
174
  // in one word anyway, and in practice one child carries the badge.
130
175
  noun: parts[0].noun,
176
+ // The WORST tone under it, not the first one and not an average. The
177
+ // parent is a closed dropdown: the reader cannot see which child is on
178
+ // fire, so the only useful thing the heading can tell them is that one
179
+ // of them is. Rolling up the mildest — or the first contributor's, as
180
+ // the noun does — would hide the one row that needed opening.
181
+ tone: parts.reduce(
182
+ (worst, part) => worseTone(worst, part.tone),
183
+ DEFAULT_BADGE_TONE
184
+ ),
131
185
  };
132
186
  }
133
187
 
@@ -140,6 +194,14 @@ export function navBadge(badges, nav) {
140
194
  *
141
195
  * The REAL count, not the capped display: "Tickets, 340 open" is useful and
142
196
  * "Tickets, 99+ open" is a rendering artefact.
197
+ *
198
+ * The TONE is not spoken, and that is a decision rather than an omission.
199
+ * There is no honest word for it: 'danger' is a class name, and "Tickets, 4
200
+ * open, danger" is speech nobody wrote. A consumer that has a real word for
201
+ * why its badge is red already has somewhere to put it — `label`, which is the
202
+ * noun this name is built from, so `{ count: 4, label: 'over a week', tone:
203
+ * 'danger' }` reads "Tickets, 4 over a week" and the colour is the same fact
204
+ * for people who can see it.
143
205
  */
144
206
  export function badgeAriaLabel(label, badge) {
145
207
  if (!badge || !label) {
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The mobile drawer's rows, derived from the navigation tree the bar draws.
3
+ *
4
+ * Split out of Navigation.jsx for the same reason navActive.js and
5
+ * navBadges.js were: what the drawer shows is a handful of judgement calls,
6
+ * every one of them testable without a DOM, a router or a stylesheet.
7
+ * Navigation.jsx renders what this file decides and decides nothing itself.
8
+ *
9
+ * THE ONE IDEA HERE IS THAT THE DRAWER IS FLAT. The header bar hangs a group's
10
+ * pages off a dropdown, which is a hover affordance wearing a keyboard escape
11
+ * hatch — on a phone there is no hover, the group heading has no page of its
12
+ * own to navigate to, and a menu that only opens on a gesture the device does
13
+ * not have is a menu nobody can reach. So a group becomes a heading followed
14
+ * by its children as ordinary rows: everything the reader may open is visible
15
+ * the moment the drawer is, and nothing in the drawer is a disclosure control.
16
+ *
17
+ * The cost is length — the drawer is as tall as the whole menu — and that is
18
+ * the trade being made deliberately. A scroll is cheaper than a tap that does
19
+ * nothing.
20
+ *
21
+ * VISIBILITY IS NOT RE-DECIDED HERE. `permission`, `hidden` and the group
22
+ * cascade are all computed by Navigation's own effect and arrive on the nodes;
23
+ * this only reads them, with exactly the tests the two rendering paths in
24
+ * Navigation.jsx already use, so a drawer can never show a page the bar would
25
+ * have withheld.
26
+ */
27
+ // The extension is not decoration: this module is imported directly by
28
+ // `node --test`, which resolves ESM specifiers to the letter, and Vite
29
+ // resolves an explicit extension just as happily as a bare one.
30
+ import { isNavActive, bestMatchingUrl, cleanUrl } from './navActive.js';
31
+
32
+ /**
33
+ * The test the header applies to a TOP-LEVEL entry.
34
+ *
35
+ * `permissionKey === ''` is a deliberate second door: an entry that names no
36
+ * permission at all is public, and the effect that stamps `permission` may not
37
+ * have run on the very first render. Both are copied verbatim from the bar's
38
+ * own filter rather than restated in a tidier form — the two must agree, and
39
+ * the cheapest way to guarantee that is for them to be the same expression.
40
+ */
41
+ const showsInBar = (nav) =>
42
+ !!nav &&
43
+ nav.hidden !== true &&
44
+ (nav.permission === true || nav.permissionKey === '');
45
+
46
+ /**
47
+ * The test the header applies to a DROPDOWN child, which is stricter: a child
48
+ * has to have been granted, not merely not withheld (see `renderChildren`).
49
+ */
50
+ const showsInDropdown = (child) =>
51
+ !!child && child.hidden !== true && child.permission === true;
52
+
53
+ /**
54
+ * @typedef {Object} DrawerRow
55
+ * @property {'link'|'heading'} kind a heading is inert — see below.
56
+ * @property {string} id the nav id, for the badge lookup.
57
+ * @property {string} label what the row reads as.
58
+ * @property {string} [url] links only.
59
+ * @property {string} [target] links only; passed through untouched.
60
+ * @property {number} depth 0 top level, 1 inside a group.
61
+ * @property {boolean} active this is the page being viewed.
62
+ */
63
+
64
+ /**
65
+ * Flatten a navigation tree into drawer rows.
66
+ *
67
+ * Four shapes go in and three come out:
68
+ *
69
+ * - a leaf (url, no children) -> one link row.
70
+ * - a group (children, no url) -> a HEADING row, then its children.
71
+ * - both (url AND children) -> a LINK row, then its children. The
72
+ * parent has a page of its own and the drawer is the one surface where
73
+ * that page is otherwise unreachable, since there is no dropdown to open
74
+ * and no hover to open it with.
75
+ * - neither (no url, no children) -> a heading with nothing under it,
76
+ * which is exactly what the bar draws for it: an inert <span>. Rendering
77
+ * nothing instead would be a quieter lie about the config.
78
+ *
79
+ * ACTIVE IS PER ROW, NOT PER BRANCH. A parent that owns children is matched on
80
+ * its OWN url only (`isNavActive(url, [], …)`, no child urls) — in the bar it
81
+ * takes the highlight for anything underneath it because the children are
82
+ * hidden inside a closed menu, but here the child row is right there and
83
+ * lighting both would say the reader is standing in two places at once.
84
+ * Children use `bestMatchingUrl` across their siblings, the same rule and the
85
+ * same call the dropdown uses, so `/reports` and `/reports/prebuilt` cannot
86
+ * both claim the page.
87
+ *
88
+ * @param {Array} navigations Navigation's `navData.navigations`.
89
+ * @param {string} currentPage the path being viewed, with or without its
90
+ * leading slash (Navigation passes it without).
91
+ * @returns {DrawerRow[]}
92
+ */
93
+ export function drawerRows(navigations, currentPage) {
94
+ const rows = [];
95
+
96
+ (Array.isArray(navigations) ? navigations : []).forEach((nav) => {
97
+ if (!showsInBar(nav)) {
98
+ return;
99
+ }
100
+
101
+ const children = (nav.children || []).filter(showsInDropdown);
102
+ // Computed once per group rather than once per child: it is a scan of
103
+ // every sibling url, and it answers the same question for all of them.
104
+ const bestChild = children.length
105
+ ? bestMatchingUrl(
106
+ children.map((child) => child.url),
107
+ currentPage
108
+ )
109
+ : null;
110
+
111
+ if (nav.url) {
112
+ rows.push({
113
+ kind: 'link',
114
+ id: nav.id,
115
+ label: nav.label,
116
+ url: nav.url,
117
+ target: nav.target,
118
+ depth: 0,
119
+ active: isNavActive(nav.url, [], currentPage),
120
+ });
121
+ } else {
122
+ rows.push({
123
+ kind: 'heading',
124
+ id: nav.id,
125
+ label: nav.label,
126
+ depth: 0,
127
+ active: false,
128
+ });
129
+ }
130
+
131
+ children.forEach((child) => {
132
+ rows.push({
133
+ kind: 'link',
134
+ id: child.id,
135
+ label: child.label,
136
+ url: child.url,
137
+ target: child.target,
138
+ depth: 1,
139
+ active: bestChild !== null && cleanUrl(child.url) === bestChild,
140
+ });
141
+ });
142
+ });
143
+
144
+ return rows;
145
+ }
146
+
147
+ export default drawerRows;
@@ -18,6 +18,7 @@ import {
18
18
  Loader2,
19
19
  MessageSquarePlus,
20
20
  Send,
21
+ ShieldCheck,
21
22
  Unplug,
22
23
  User,
23
24
  X,
@@ -47,6 +48,7 @@ import {
47
48
  resolveAnnotations,
48
49
  segmentCount,
49
50
  statusLabel,
51
+ threadCanReply,
50
52
  threadDisplayName,
51
53
  threadSubtitle,
52
54
  upsertMessage,
@@ -383,7 +385,14 @@ const SmsThreadPanel = ({
383
385
 
384
386
  const counts = segmentCount(body);
385
387
  const overLimit = counts.segments > MAX_SEGMENTS;
386
- const canSend = body.trim() !== '' && !overLimit && !sending && Boolean(threadId);
388
+
389
+ // A sender-ID thread (`Apple`, `ANZ`, a short code) receives and can never
390
+ // answer. The server refuses the send with a 422 of its own; this is what
391
+ // stops anybody getting that far, and it gates `canSend` as well as the
392
+ // footer so a keyboard shortcut cannot slip past the missing button.
393
+ const canReply = threadCanReply(thread);
394
+ const canSend =
395
+ canReply && body.trim() !== '' && !overLimit && !sending && Boolean(threadId);
387
396
 
388
397
  const send = useCallback(
389
398
  (text) => {
@@ -990,12 +999,31 @@ const SmsThreadPanel = ({
990
999
  </div>
991
1000
 
992
1001
  {/*
993
- * While selecting, the action bar stands where the composer does.
994
- * Both at once would be a pane with two footers and two primary
995
- * buttons, and picking messages out of a conversation is not
996
- * something anybody does halfway through writing a reply.
1002
+ * One footer, three things it can be.
1003
+ *
1004
+ * Receive-only wins outright: the compose box is REPLACED rather
1005
+ * than disabled, because a dead Send button beside a live textarea
1006
+ * reads as something being broken, where a sentence says why there
1007
+ * is nothing to press — the only thing a reader wants to know when
1008
+ * a two-factor code has just landed in front of them.
1009
+ *
1010
+ * Otherwise, while selecting, the action bar stands where the
1011
+ * composer does. Both at once would be a pane with two footers and
1012
+ * two primary buttons, and picking messages out of a conversation
1013
+ * is not something anybody does halfway through writing a reply.
997
1014
  */}
998
- {selecting ? (
1015
+ {!canReply ? (
1016
+ <div className={styles.receiveOnly}>
1017
+ <div className={styles.banner}>
1018
+ <ShieldCheck size={15} strokeWidth={2} aria-hidden="true" />
1019
+ <span>
1020
+ <strong>{threadDisplayName(thread)}</strong> is a sender ID,
1021
+ not a phone number — messages from it are one-way and
1022
+ cannot be replied to.
1023
+ </span>
1024
+ </div>
1025
+ </div>
1026
+ ) : selecting ? (
999
1027
  <div className={styles.selectBar}>
1000
1028
  <span className={styles.selectCount}>
1001
1029
  {selectedIds.length === 0
@@ -645,6 +645,21 @@ export const threadDisplayName = (thread) => {
645
645
  );
646
646
  };
647
647
 
648
+ /**
649
+ * Can this thread be answered, or does it only ever receive?
650
+ *
651
+ * A thread opened by an inbound message from an alphanumeric sender ID -
652
+ * `Apple` for a two-factor code, `ANZ`, a courier's short code - has no handset
653
+ * behind it. The SERVER decides this and sends `can_reply`; deriving it here
654
+ * from the shape of `external_number` would be a second definition of the rule,
655
+ * and the derived one is always the one that goes wrong.
656
+ *
657
+ * `undefined` means an older backend that predates the field, and that is read
658
+ * as repliable: a missing key must not silently take the compose box away from
659
+ * every ordinary conversation.
660
+ */
661
+ export const threadCanReply = (thread) => thread?.can_reply !== false;
662
+
648
663
  /** The number under the name, only when it is not already the name. */
649
664
  export const threadSubtitle = (thread) => {
650
665
  if (!thread) return '';