@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.
- package/package.json +4 -2
- package/src/components/Autocomplete.jsx +189 -119
- package/src/components/DataGrid.jsx +472 -31
- package/src/components/Navigation.jsx +475 -51
- package/src/components/auth/ClientAuthFrame.jsx +5 -0
- package/src/components/auth/ClientAuthScreen.jsx +29 -0
- package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
- package/src/components/callQueue/CallQueuePop.jsx +713 -90
- package/src/components/callQueue/CallQueueSettings.jsx +308 -146
- package/src/components/callQueue/callPopStatus.js +236 -0
- package/src/components/callQueue/callQueueHelpers.js +284 -2
- package/src/components/columns/ColumnRenderers.jsx +3 -46
- package/src/components/columns/StackedRow.jsx +186 -0
- package/src/components/controls/DataGridSearch.jsx +110 -2
- package/src/components/controls/DataGridSortSheet.jsx +155 -0
- package/src/components/generic/GenericAuth.jsx +50 -18
- package/src/components/generic/GenericDashboard.jsx +20 -1
- package/src/components/generic/GenericDetail.jsx +446 -259
- package/src/components/mapboxSearchBox.js +640 -0
- package/src/components/navBadges.js +63 -1
- package/src/components/navDrawer.js +147 -0
- package/src/components/sms/SmsThreadPanel.jsx +34 -6
- package/src/components/sms/smsHelpers.js +15 -0
- package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
- package/src/components/styles/CallQueuePop.module.scss +29 -0
- package/src/components/styles/CallQueueSettings.module.scss +93 -0
- package/src/components/styles/ClientAuth.module.scss +39 -0
- package/src/components/styles/DataGrid.module.scss +158 -5
- package/src/components/styles/Field.module.scss +52 -1
- package/src/components/styles/Form.module.scss +82 -0
- package/src/components/styles/GenericClientPortal.module.scss +72 -20
- package/src/components/styles/GenericDashboard.module.scss +50 -0
- package/src/components/styles/GenericDetail.module.scss +63 -1
- package/src/components/styles/GenericDynamic.module.scss +23 -0
- package/src/components/styles/GenericFormBuilder.module.scss +11 -0
- package/src/components/styles/GenericIndex.module.scss +6 -1
- package/src/components/styles/Navigation.module.scss +460 -7
- package/src/components/styles/Sms.module.scss +92 -0
- package/src/components/styles/StackedRow.module.scss +182 -0
- package/src/components/styles/TicketConversation.module.scss +76 -0
- package/src/components/styles/Vault.module.scss +192 -0
- package/src/components/styles/density.css +10 -0
- package/src/components/styles/global-datagrid.css +163 -0
- package/src/components/styles/global.css +20 -0
- package/src/components/tickets/TicketConversation.jsx +13 -8
- package/src/components/utils/ConfirmDialog.js +22 -3
- package/src/components/utils/cardLayout.js +666 -0
- package/src/components/utils/contactChannels.js +130 -0
- package/src/components/utils/editPlacement.js +95 -0
- package/src/components/utils/useDensity.js +303 -7
- 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
|
-
|
|
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
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
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
|
-
{
|
|
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 '';
|