@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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phone and email formatting, and the hrefs that make them dialable.
|
|
3
|
+
*
|
|
4
|
+
* No React and no CSS import, for the same reason `utils/displayValue.js` has
|
|
5
|
+
* neither: the rules encoded here are testable under `node --test` rather than
|
|
6
|
+
* inferred from a rendered tree, and every consumer of a `.jsx` in this repo
|
|
7
|
+
* has to bring a bundler with it.
|
|
8
|
+
*
|
|
9
|
+
* `formatPhoneNumber` LIVED IN `columns/ColumnRenderers.jsx` and is moved here
|
|
10
|
+
* unchanged. It has two callers now — the phone column renderer, which formats
|
|
11
|
+
* a number for display, and the card row, which needs the same string beside a
|
|
12
|
+
* `tel:` href. Two copies of an Australian numbering table is how one screen
|
|
13
|
+
* starts spelling a mobile differently from the next.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An Australian phone number, spaced the way the country writes it.
|
|
18
|
+
*
|
|
19
|
+
* Anything that does not answer to a known pattern is returned VERBATIM rather
|
|
20
|
+
* than forced into one — an international number, an extension, a note somebody
|
|
21
|
+
* typed into the field. Reformatting a string we did not recognise is how a
|
|
22
|
+
* number becomes undialable.
|
|
23
|
+
*
|
|
24
|
+
* @param {*} phoneNumber raw field value
|
|
25
|
+
* @returns {string} formatted number, or `''` for a blank input
|
|
26
|
+
*/
|
|
27
|
+
export const formatPhoneNumber = (phoneNumber) => {
|
|
28
|
+
if (!phoneNumber) return '';
|
|
29
|
+
|
|
30
|
+
// Remove all non-digit characters. `String()` is the ONE change made in the
|
|
31
|
+
// move out of ColumnRenderers: the column renderer hands this whatever the
|
|
32
|
+
// API put in the field, and a numeric phone column used to throw here. It
|
|
33
|
+
// can only turn a crash into a result — every string input takes the same
|
|
34
|
+
// branch it always did.
|
|
35
|
+
let cleanNumber = String(phoneNumber).replace(/\D/g, '');
|
|
36
|
+
|
|
37
|
+
// +61 spellings collapse to the local form so every branch below applies.
|
|
38
|
+
if (cleanNumber.match(/^61[2-9]\d{8}$/)) {
|
|
39
|
+
cleanNumber = '0' + cleanNumber.slice(2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if it's an Australian mobile number (starts with 04 and has 10 digits)
|
|
43
|
+
if (cleanNumber.match(/^04\d{8}$/)) {
|
|
44
|
+
// Mobile format: 0400 000 000
|
|
45
|
+
return cleanNumber.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check if it's a 1300/1800 number (10 digits starting with 1300 or 1800)
|
|
49
|
+
if (cleanNumber.match(/^1[38]00\d{6}$/)) {
|
|
50
|
+
// 1300/1800 format: 1300 000 000
|
|
51
|
+
return cleanNumber.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check if it's an Australian landline number (8 digits with area code, or 10 digits total)
|
|
55
|
+
if (cleanNumber.match(/^0[2-9]\d{8}$/)) {
|
|
56
|
+
// Landline format: (02) 0000 0000
|
|
57
|
+
return cleanNumber.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2 $3');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// If it doesn't match standard Australian patterns, return as is with some basic formatting
|
|
61
|
+
if (cleanNumber.length >= 8) {
|
|
62
|
+
// Generic formatting for longer numbers
|
|
63
|
+
if (cleanNumber.length === 8) {
|
|
64
|
+
return cleanNumber.replace(/(\d{4})(\d{4})/, '$1 $2');
|
|
65
|
+
} else if (cleanNumber.length === 9) {
|
|
66
|
+
return cleanNumber.replace(/(\d{1})(\d{4})(\d{4})/, '$1 $2 $3');
|
|
67
|
+
} else if (cleanNumber.length === 10) {
|
|
68
|
+
return cleanNumber.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2 $3');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Return original if no formatting pattern matches
|
|
73
|
+
return phoneNumber;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `tel:` target for a stored number.
|
|
78
|
+
*
|
|
79
|
+
* DELIBERATELY NOT `formatPhoneNumber`'s output: the spaces and brackets that
|
|
80
|
+
* make a number readable are not part of a dial string, and a leading `+` is
|
|
81
|
+
* the one non-digit that is — dropping it turns an international number into a
|
|
82
|
+
* local one that reaches somebody else entirely.
|
|
83
|
+
*
|
|
84
|
+
* Returns `null` when there is nothing to dial, so the caller renders plain
|
|
85
|
+
* text rather than a dead anchor.
|
|
86
|
+
*
|
|
87
|
+
* @param {*} value raw field value
|
|
88
|
+
* @returns {string|null}
|
|
89
|
+
*/
|
|
90
|
+
export const telHref = (value) => {
|
|
91
|
+
if (value === null || value === undefined) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const raw = String(value).trim();
|
|
96
|
+
|
|
97
|
+
if (raw === '') {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const digits = raw.replace(/[^\d+]/g, '');
|
|
102
|
+
// A `+` anywhere but the front is noise from a pasted string.
|
|
103
|
+
const normalised = digits.startsWith('+')
|
|
104
|
+
? '+' + digits.slice(1).replace(/\+/g, '')
|
|
105
|
+
: digits.replace(/\+/g, '');
|
|
106
|
+
|
|
107
|
+
return normalised.replace(/\D/g, '').length >= 4 ? `tel:${normalised}` : null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The `mailto:` target for a stored address.
|
|
112
|
+
*
|
|
113
|
+
* The bar is deliberately low — one `@` with something either side. This is a
|
|
114
|
+
* display decision, not validation: the address came out of the database and
|
|
115
|
+
* refusing to link it does not make it any more correct, while linking
|
|
116
|
+
* something that is plainly not an address (a note, a dash) draws a dead
|
|
117
|
+
* anchor.
|
|
118
|
+
*
|
|
119
|
+
* @param {*} value raw field value
|
|
120
|
+
* @returns {string|null}
|
|
121
|
+
*/
|
|
122
|
+
export const mailtoHref = (value) => {
|
|
123
|
+
if (value === null || value === undefined) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const raw = String(value).trim();
|
|
128
|
+
|
|
129
|
+
return /^[^\s@]+@[^\s@]+$/.test(raw) ? `mailto:${raw}` : null;
|
|
130
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a detail page's action cluster sits — the Edit button and the Save /
|
|
3
|
+
* Cancel pair that replace it, plus a table tab's bulk actions.
|
|
4
|
+
*
|
|
5
|
+
* No React and no CSS import, on the `utils/cardLayout.js` precedent: the only
|
|
6
|
+
* *decision* in the change is which of two placements a view config asked for,
|
|
7
|
+
* it is read by every detail page in every application, and it is testable
|
|
8
|
+
* under `node --test` rather than by squinting at a rendered tree.
|
|
9
|
+
*
|
|
10
|
+
* ---------------------------------------------------------------------------
|
|
11
|
+
* WHY THERE IS A CHOICE AT ALL
|
|
12
|
+
*
|
|
13
|
+
* The cluster has always been `position: fixed; bottom: 15px; right: 20px`, and
|
|
14
|
+
* every detail page in every VISNS CRM is laid out around that. It is the wrong
|
|
15
|
+
* place twice over and right once:
|
|
16
|
+
*
|
|
17
|
+
* - on a SHORT record the button is stranded in the bottom-right corner of an
|
|
18
|
+
* empty viewport, a screen away from the six fields it edits;
|
|
19
|
+
* - on a LONG one it floats over the content, and on a phone it lands on top
|
|
20
|
+
* of the row actions of whatever grid is under it;
|
|
21
|
+
* - but on a page that is one tall form, a control that follows the reader
|
|
22
|
+
* down is exactly right, and there are a lot of those.
|
|
23
|
+
*
|
|
24
|
+
* So this is not a fix, it is a placement, and the default does not move. A
|
|
25
|
+
* host that says nothing gets the floating bar it has always had, byte for
|
|
26
|
+
* byte; `header` is opt-in per view config.
|
|
27
|
+
*
|
|
28
|
+
* ---------------------------------------------------------------------------
|
|
29
|
+
* WHY THE CONFIG IS THE PRIMARY INPUT AND THE PROP ONLY OVERRIDES IT
|
|
30
|
+
*
|
|
31
|
+
* The consuming CRMs drive their detail pages from JSON view configs, and most
|
|
32
|
+
* of those are mounted by a bare `<GenericDetail setting={SomeDetail} />` with
|
|
33
|
+
* no host JSX to edit — several through a generic route table that has never
|
|
34
|
+
* heard of the page it is rendering. A prop-only switch would mean a JSX change
|
|
35
|
+
* per page to move a button, which is how a layout choice ends up applied to
|
|
36
|
+
* three pages out of thirty and nobody can say why.
|
|
37
|
+
*
|
|
38
|
+
* The prop still wins where it is given, because a host that has wrapped
|
|
39
|
+
* GenericDetail for one page (a facts band, a custom tab) is the one caller
|
|
40
|
+
* that knows something the config does not.
|
|
41
|
+
*
|
|
42
|
+
* An unknown value is NOT an error and must not become one: a config written
|
|
43
|
+
* against a later version of this library, or a typo, has to render the page
|
|
44
|
+
* rather than take it down over a button's coordinates. Unknown falls back to
|
|
45
|
+
* the default, which is also what every existing config means.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** The floating bottom-right bar. Today's behaviour, and the default. */
|
|
49
|
+
export const EDIT_PLACEMENT_FLOATING = 'floating';
|
|
50
|
+
|
|
51
|
+
/** An inline cluster on the right of the record's title row. */
|
|
52
|
+
export const EDIT_PLACEMENT_HEADER = 'header';
|
|
53
|
+
|
|
54
|
+
/** Every placement this library understands. */
|
|
55
|
+
export const EDIT_PLACEMENTS = [
|
|
56
|
+
EDIT_PLACEMENT_FLOATING,
|
|
57
|
+
EDIT_PLACEMENT_HEADER,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the placement for one detail page.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} [setting] the view config GenericDetail was handed — read
|
|
64
|
+
* as `setting.editPlacement`, deliberately at the
|
|
65
|
+
* config ROOT beside `page` / `filters` / `tabs`
|
|
66
|
+
* rather than per tab: it is a statement about the
|
|
67
|
+
* page's chrome, and a button that moved as you
|
|
68
|
+
* changed tabs would read as a rendering fault.
|
|
69
|
+
* @param {string} [override] the `editPlacement` prop, when a host passed one.
|
|
70
|
+
* @returns {'floating'|'header'}
|
|
71
|
+
*/
|
|
72
|
+
export function resolveEditPlacement(setting, override) {
|
|
73
|
+
if (EDIT_PLACEMENTS.includes(override)) {
|
|
74
|
+
return override;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fromConfig = setting?.editPlacement;
|
|
78
|
+
|
|
79
|
+
if (EDIT_PLACEMENTS.includes(fromConfig)) {
|
|
80
|
+
return fromConfig;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return EDIT_PLACEMENT_FLOATING;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether the resolved placement is the header one.
|
|
88
|
+
*
|
|
89
|
+
* A one-line convenience so the render reads as a question about the page
|
|
90
|
+
* rather than a string comparison repeated at four call sites — the shape that
|
|
91
|
+
* eventually gets one of them wrong.
|
|
92
|
+
*/
|
|
93
|
+
export function isHeaderPlacement(placement) {
|
|
94
|
+
return placement === EDIT_PLACEMENT_HEADER;
|
|
95
|
+
}
|
|
@@ -17,6 +17,28 @@ import { useMemo, useState, useEffect, useSyncExternalStore } from 'react';
|
|
|
17
17
|
* to lay out in JS — the datagrid measures and sizes rows in px — reads them
|
|
18
18
|
* through `useDensity()` so there is one table of numbers instead of a
|
|
19
19
|
* `isTabletMode ? 52 : 32` at every call site.
|
|
20
|
+
*
|
|
21
|
+
* ---------------------------------------------------------------------------
|
|
22
|
+
* AND A THIRD SWITCH NOBODY HAS TO SET: `(pointer: coarse)`.
|
|
23
|
+
*
|
|
24
|
+
* Tablet mode was per-view opt-in only, which meant the 44px tap targets and
|
|
25
|
+
* the 52px rows existed on precisely the handful of views whose JSON happened
|
|
26
|
+
* to carry `"tabletMode": true` — and nowhere else, including on every phone
|
|
27
|
+
* and tablet opening every other grid in the product. A 32px row with a 28px
|
|
28
|
+
* icon button in it is not a target; the finger covers it and the tap lands on
|
|
29
|
+
* the row underneath.
|
|
30
|
+
*
|
|
31
|
+
* So a coarse primary pointer now counts as tablet mode on its own. `pointer`
|
|
32
|
+
* reports the PRIMARY pointing device, not "is touch available anywhere": a
|
|
33
|
+
* laptop with a touchscreen and a trackpad answers `fine` and is unaffected,
|
|
34
|
+
* which is the reason this is safe to turn on for everybody. A phone, a
|
|
35
|
+
* tablet and a kiosk answer `coarse` and get the roomy geometry without a line
|
|
36
|
+
* of configuration.
|
|
37
|
+
*
|
|
38
|
+
* The explicit switch still works and still means the same thing — it is now
|
|
39
|
+
* "treat this view as touch even on a mouse", which is exactly what the plant
|
|
40
|
+
* floor asked for when a gloved hand is driving a desktop. The two are ORed,
|
|
41
|
+
* never checked against each other.
|
|
20
42
|
*/
|
|
21
43
|
|
|
22
44
|
/**
|
|
@@ -77,9 +99,209 @@ export const DEFAULT_DENSITY = 'default';
|
|
|
77
99
|
const SERVER_SNAPSHOT = `${DEFAULT_DENSITY}|false`;
|
|
78
100
|
|
|
79
101
|
/**
|
|
80
|
-
*
|
|
102
|
+
* The media query that stands in for "this is being driven by a finger".
|
|
103
|
+
*
|
|
104
|
+
* Exported so the CSS side can be grepped against one string rather than four
|
|
105
|
+
* hand-typed copies drifting apart — `styles/DataGrid.module.scss`,
|
|
106
|
+
* `styles/global-datagrid.css` and `styles/density.css` all carry this exact
|
|
107
|
+
* condition beside their `body.tablet-mode` selectors.
|
|
108
|
+
*/
|
|
109
|
+
export const COARSE_POINTER_QUERY = '(pointer: coarse)';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The width below which a grid stops trying to fit its columns on screen.
|
|
113
|
+
*
|
|
114
|
+
* DELIBERATELY NOT PART OF `DENSITY_GRID`, and not folded into the density
|
|
115
|
+
* snapshot either. Density answers "how big does a target have to be"; this
|
|
116
|
+
* answers "is there enough width to lay seven columns out side by side". They
|
|
117
|
+
* are different questions with different answers — a phone is coarse AND
|
|
118
|
+
* narrow, a kiosk is coarse and 1920px wide, a desktop browser dragged to a
|
|
119
|
+
* third of the screen is narrow and fine — and `tests/density.test.mjs` pins
|
|
120
|
+
* both the density table and the parsed snapshot shape literally, on the
|
|
121
|
+
* reasoning that those numbers are every existing deployment's layout.
|
|
122
|
+
*
|
|
123
|
+
* Exported so the CSS half can be grepped against one string rather than
|
|
124
|
+
* hand-typed copies drifting apart — `styles/global-datagrid.css` carries this
|
|
125
|
+
* exact width.
|
|
126
|
+
*/
|
|
127
|
+
export const NARROW_VIEWPORT_QUERY = '(max-width: 640px)';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The floor a column gets when the viewport is narrow, whatever the density.
|
|
131
|
+
*
|
|
132
|
+
* `DENSITY_GRID.columnMinWidth` is 80px, which is what seven flex columns
|
|
133
|
+
* collapse to on a phone — and 80px is not a column, it is an ellipsis. The
|
|
134
|
+
* measured widths on /contacts: "First Name" needs 127px of label alone,
|
|
135
|
+
* "Work Phone" 136px, before the sort icon beside it. Every header rendered as
|
|
136
|
+
* "FI…", "LA…", "TI…" and every cell truncated with it.
|
|
137
|
+
*
|
|
138
|
+
* 160px clears the widest of those with room for the icon. Seven of them is
|
|
139
|
+
* 1120px inside a 356px grid, which is the honest answer: the grid scrolls
|
|
140
|
+
* sideways INSIDE ITS OWN BODY (`.InovuaReactDataGrid__body` is already the
|
|
141
|
+
* scroller — the page itself does not move) and every column is readable when
|
|
142
|
+
* it is reached. The alternative on offer is seven columns of nothing.
|
|
143
|
+
*
|
|
144
|
+
* A view config's own `column.minWidth` still wins, so a genuinely narrow
|
|
145
|
+
* column (a quantity, a status chip) is one line of JSON away.
|
|
146
|
+
*/
|
|
147
|
+
export const NARROW_COLUMN_MIN_WIDTH = 160;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether a media query currently matches, as a `useSyncExternalStore` pair.
|
|
151
|
+
*
|
|
152
|
+
* Same guards as `hasCoarsePointer` and for the same reasons: `matchMedia` is
|
|
153
|
+
* missing in jsdom and in every server render, and Safari only grew
|
|
154
|
+
* `addEventListener` on MediaQueryList in 14. Absent means `false`, which is
|
|
155
|
+
* the pre-existing behaviour in both cases — a desktop-width grid.
|
|
156
|
+
*/
|
|
157
|
+
const mediaMatches = (query) => {
|
|
158
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
return window.matchMedia(query).matches;
|
|
164
|
+
} catch (unsupported) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const subscribeToQuery = (query) => (onChange) => {
|
|
170
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
|
171
|
+
return () => {};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const list = window.matchMedia(query);
|
|
176
|
+
|
|
177
|
+
if (typeof list.addEventListener === 'function') {
|
|
178
|
+
list.addEventListener('change', onChange);
|
|
179
|
+
|
|
180
|
+
return () => list.removeEventListener('change', onChange);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (typeof list.addListener === 'function') {
|
|
184
|
+
list.addListener(onChange);
|
|
185
|
+
|
|
186
|
+
return () => list.removeListener(onChange);
|
|
187
|
+
}
|
|
188
|
+
} catch (unsupported) {
|
|
189
|
+
// A browser that cannot evaluate the query never reports a change; the
|
|
190
|
+
// snapshot already read `false` for the same reason.
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return () => {};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Whether the viewport is too narrow to lay columns out side by side.
|
|
198
|
+
*
|
|
199
|
+
* @returns {boolean}
|
|
200
|
+
*/
|
|
201
|
+
export const useNarrowViewport = () => {
|
|
202
|
+
const subscribe = useMemo(
|
|
203
|
+
() => subscribeToQuery(NARROW_VIEWPORT_QUERY),
|
|
204
|
+
[]
|
|
205
|
+
);
|
|
206
|
+
const getSnapshot = useMemo(
|
|
207
|
+
() => () => mediaMatches(NARROW_VIEWPORT_QUERY),
|
|
208
|
+
[]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The attribute an app writes to turn phone card rows on for every grid in it.
|
|
216
|
+
*
|
|
217
|
+
* Same mechanism as `data-density` and for the same reason: it is set by
|
|
218
|
+
* whoever owns the page — GenericAuth, from its `cardRows` prop — rather than
|
|
219
|
+
* by the component reading it, and it has to be readable from CSS as well as
|
|
220
|
+
* from JS.
|
|
221
|
+
*
|
|
222
|
+
* OPT-IN AT THE LIBRARY, because three applications consume this and a
|
|
223
|
+
* heuristic that titles an unreviewed view badly is a worse phone experience
|
|
224
|
+
* than the sideways-scrolling table it replaced. A view can still turn card
|
|
225
|
+
* rows on for itself with `tableSetting.cardLayout`, and off with
|
|
226
|
+
* `cardLayout: false`, whatever this attribute says.
|
|
227
|
+
*/
|
|
228
|
+
export const CARD_ROWS_ATTRIBUTE = 'data-card-rows';
|
|
229
|
+
|
|
230
|
+
const readCardRows = () => {
|
|
231
|
+
if (typeof document === 'undefined' || !document.documentElement) {
|
|
232
|
+
return 'off';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
document.documentElement.getAttribute(CARD_ROWS_ATTRIBUTE) || 'off'
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const subscribeToCardRows = (onChange) => {
|
|
241
|
+
if (
|
|
242
|
+
typeof document === 'undefined' ||
|
|
243
|
+
typeof MutationObserver === 'undefined' ||
|
|
244
|
+
!document.documentElement
|
|
245
|
+
) {
|
|
246
|
+
return () => {};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const observer = new MutationObserver(onChange);
|
|
250
|
+
|
|
251
|
+
observer.observe(document.documentElement, {
|
|
252
|
+
attributes: true,
|
|
253
|
+
attributeFilter: [CARD_ROWS_ATTRIBUTE],
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return () => observer.disconnect();
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Whether this application has opted its grids into phone card rows.
|
|
261
|
+
*
|
|
262
|
+
* @returns {boolean}
|
|
263
|
+
*/
|
|
264
|
+
export const useCardRowsEnabled = () => {
|
|
265
|
+
const enabled = useSyncExternalStore(
|
|
266
|
+
subscribeToCardRows,
|
|
267
|
+
readCardRows,
|
|
268
|
+
() => 'off'
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
return enabled === 'on' || enabled === 'true';
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Whether the primary pointing device is coarse.
|
|
276
|
+
*
|
|
277
|
+
* Wrapped rather than called inline because `matchMedia` is missing in jsdom
|
|
278
|
+
* and in every server render, and a hook that throws during the first snapshot
|
|
279
|
+
* takes the whole page with it. Absent means `false`, which is the pre-existing
|
|
280
|
+
* behaviour and the conservative answer: a mouse-sized target on a touch screen
|
|
281
|
+
* is awkward, a touch-sized one on a desktop grid is wrong.
|
|
282
|
+
*/
|
|
283
|
+
const hasCoarsePointer = () => {
|
|
284
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
return window.matchMedia(COARSE_POINTER_QUERY).matches;
|
|
290
|
+
} catch (unsupported) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Read every switch off the document as one string. A string (not an object)
|
|
81
297
|
* so `useSyncExternalStore` can compare snapshots by value — returning a fresh
|
|
82
298
|
* object from `getSnapshot` makes React re-render forever.
|
|
299
|
+
*
|
|
300
|
+
* The class and the pointer are ORed into ONE flag rather than reported
|
|
301
|
+
* separately: everything downstream — the row heights, the action column, the
|
|
302
|
+
* checkbox column — wants the same answer to the same question, which is "how
|
|
303
|
+
* big does a target have to be here". Which of the two switches made it true
|
|
304
|
+
* is not a distinction any caller has ever needed.
|
|
83
305
|
*/
|
|
84
306
|
const readSnapshot = () => {
|
|
85
307
|
if (typeof document === 'undefined') {
|
|
@@ -89,9 +311,9 @@ const readSnapshot = () => {
|
|
|
89
311
|
const density =
|
|
90
312
|
document.documentElement?.getAttribute('data-density') ||
|
|
91
313
|
DEFAULT_DENSITY;
|
|
92
|
-
const isTablet =
|
|
93
|
-
document.body && document.body.classList.contains('tablet-mode')
|
|
94
|
-
|
|
314
|
+
const isTablet =
|
|
315
|
+
Boolean(document.body && document.body.classList.contains('tablet-mode')) ||
|
|
316
|
+
hasCoarsePointer();
|
|
95
317
|
|
|
96
318
|
return `${density}|${isTablet}`;
|
|
97
319
|
};
|
|
@@ -99,9 +321,16 @@ const readSnapshot = () => {
|
|
|
99
321
|
const getServerSnapshot = () => SERVER_SNAPSHOT;
|
|
100
322
|
|
|
101
323
|
/**
|
|
102
|
-
* Notify on
|
|
324
|
+
* Notify on any switch changing. Two observers rather than one on the
|
|
103
325
|
* document, because we only care about one attribute on each of two nodes and
|
|
104
|
-
* a subtree observer would fire on every class change anywhere in the app
|
|
326
|
+
* a subtree observer would fire on every class change anywhere in the app —
|
|
327
|
+
* plus one media-query listener for the pointer.
|
|
328
|
+
*
|
|
329
|
+
* The pointer listener is not theoretical: an iPad with a Magic Keyboard
|
|
330
|
+
* attached answers `fine` and detached answers `coarse`, and the change
|
|
331
|
+
* arrives with no resize, no navigation and no mutation of the document. Every
|
|
332
|
+
* grid on screen has to re-measure when it happens, or the rows stay at
|
|
333
|
+
* whatever height they were built for.
|
|
105
334
|
*/
|
|
106
335
|
const subscribe = (onChange) => {
|
|
107
336
|
if (
|
|
@@ -112,6 +341,31 @@ const subscribe = (onChange) => {
|
|
|
112
341
|
}
|
|
113
342
|
|
|
114
343
|
const observers = [];
|
|
344
|
+
/* Collected separately from `observers` because a MediaQueryList is
|
|
345
|
+
detached with `removeEventListener`, not `disconnect()`. */
|
|
346
|
+
const detachers = [];
|
|
347
|
+
|
|
348
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
349
|
+
try {
|
|
350
|
+
const pointer = window.matchMedia(COARSE_POINTER_QUERY);
|
|
351
|
+
|
|
352
|
+
// Safari only grew `addEventListener` on MediaQueryList in 14; the
|
|
353
|
+
// deprecated `addListener` is the fallback and still works
|
|
354
|
+
// everywhere. Neither is guaranteed, hence the guards.
|
|
355
|
+
if (typeof pointer.addEventListener === 'function') {
|
|
356
|
+
pointer.addEventListener('change', onChange);
|
|
357
|
+
detachers.push(() =>
|
|
358
|
+
pointer.removeEventListener('change', onChange)
|
|
359
|
+
);
|
|
360
|
+
} else if (typeof pointer.addListener === 'function') {
|
|
361
|
+
pointer.addListener(onChange);
|
|
362
|
+
detachers.push(() => pointer.removeListener(onChange));
|
|
363
|
+
}
|
|
364
|
+
} catch (unsupported) {
|
|
365
|
+
// A browser that cannot evaluate the query simply never reports a
|
|
366
|
+
// change; the snapshot already read `false` for the same reason.
|
|
367
|
+
}
|
|
368
|
+
}
|
|
115
369
|
|
|
116
370
|
if (document.documentElement) {
|
|
117
371
|
const rootObserver = new MutationObserver(onChange);
|
|
@@ -131,7 +385,10 @@ const subscribe = (onChange) => {
|
|
|
131
385
|
observers.push(bodyObserver);
|
|
132
386
|
}
|
|
133
387
|
|
|
134
|
-
return () =>
|
|
388
|
+
return () => {
|
|
389
|
+
observers.forEach((observer) => observer.disconnect());
|
|
390
|
+
detachers.forEach((detach) => detach());
|
|
391
|
+
};
|
|
135
392
|
};
|
|
136
393
|
|
|
137
394
|
/**
|
|
@@ -385,6 +642,45 @@ export const rowsForHeight = (height, chrome, rowUnit) => {
|
|
|
385
642
|
return Math.max(1, Math.floor(usable / rowUnit));
|
|
386
643
|
};
|
|
387
644
|
|
|
645
|
+
/**
|
|
646
|
+
* The shortest a grid may be drawn: its own chrome, plus room for `minRows`.
|
|
647
|
+
*
|
|
648
|
+
* THIS IS THE FLOOR THAT WAS MISSING, and its absence is why a grid on a phone
|
|
649
|
+
* renders a column header, a filter row, a pager and NO DATA AT ALL.
|
|
650
|
+
*
|
|
651
|
+
* `useAvailableHeight` answers "how much room is left between this element's
|
|
652
|
+
* top edge and the bottom of the window", and that answer is applied to the
|
|
653
|
+
* grid verbatim. On a phone the app header, the page title and a six-tile stat
|
|
654
|
+
* strip that wraps to three rows put the grid's top edge 610px down a 740px
|
|
655
|
+
* viewport, so the answer is 130px — while the grid's own furniture (a 39px
|
|
656
|
+
* column header, a 43px filter row and a 41px pager) is 123px. The body is
|
|
657
|
+
* handed the remaining SEVEN pixels, less than a row in any density, so the
|
|
658
|
+
* grid paints its chrome and nothing else.
|
|
659
|
+
*
|
|
660
|
+
* `availableHeightFrom` cannot catch this: it only rejects a measurement at or
|
|
661
|
+
* below zero, and 130 is a perfectly positive number. The measurement is not
|
|
662
|
+
* wrong — there really is only 130px left — it is just not a height any grid
|
|
663
|
+
* can be. A grid shorter than its own chrome is never the right answer on any
|
|
664
|
+
* device, so the floor is unconditional rather than guarded on width: below it
|
|
665
|
+
* the grid overflows its slot and THE PAGE SCROLLS, which is the behaviour
|
|
666
|
+
* everybody already expects from a page whose content does not fit.
|
|
667
|
+
*
|
|
668
|
+
* It only ever engages when `available` is smaller than the floor, so a
|
|
669
|
+
* desktop index page — which gets several hundred px — is untouched.
|
|
670
|
+
*
|
|
671
|
+
* @param {number} chrome column header + filter row + footer + pager.
|
|
672
|
+
* @param {number} pitch the height one data row occupies.
|
|
673
|
+
* @param {number} minRows the fewest rows worth drawing a grid for.
|
|
674
|
+
* @returns {number}
|
|
675
|
+
*/
|
|
676
|
+
export const gridHeightFloor = (chrome, pitch, minRows) => {
|
|
677
|
+
const rows = Number.isFinite(minRows) && minRows > 0 ? minRows : 1;
|
|
678
|
+
const safeChrome = Number.isFinite(chrome) && chrome > 0 ? chrome : 0;
|
|
679
|
+
const safePitch = Number.isFinite(pitch) && pitch > 0 ? pitch : 0;
|
|
680
|
+
|
|
681
|
+
return safeChrome + rows * safePitch;
|
|
682
|
+
};
|
|
683
|
+
|
|
388
684
|
/** How long to wait after a layout change before re-measuring, in ms. */
|
|
389
685
|
export const REMEASURE_DEBOUNCE_MS = 100;
|
|
390
686
|
|
package/src/index.js
CHANGED
|
@@ -48,6 +48,26 @@ import Table from './components/DataGrid';
|
|
|
48
48
|
import TableFilter from './components/TableFilter';
|
|
49
49
|
import CellWithTooltip from './components/cells/CellWithTooltip';
|
|
50
50
|
import DataGridSearch from './components/controls/DataGridSearch';
|
|
51
|
+
// Phone card rows: the pure mapper that decides what a stacked row says, and
|
|
52
|
+
// the row itself. Exported so a consuming app can check what its own view
|
|
53
|
+
// configs derive to without mounting a grid at 375px.
|
|
54
|
+
import StackedRow from './components/columns/StackedRow';
|
|
55
|
+
import {
|
|
56
|
+
deriveCardLayout,
|
|
57
|
+
cardRowHeight,
|
|
58
|
+
CARD_COLUMN_NAME,
|
|
59
|
+
MAX_BODY_LINES,
|
|
60
|
+
} from './components/utils/cardLayout';
|
|
61
|
+
// Where a detail page's Edit button sits. Exported for the same reason
|
|
62
|
+
// `deriveCardLayout` is: a consuming app can check what its own view configs
|
|
63
|
+
// resolve to without mounting GenericDetail.
|
|
64
|
+
import {
|
|
65
|
+
resolveEditPlacement,
|
|
66
|
+
isHeaderPlacement,
|
|
67
|
+
EDIT_PLACEMENTS,
|
|
68
|
+
EDIT_PLACEMENT_FLOATING,
|
|
69
|
+
EDIT_PLACEMENT_HEADER,
|
|
70
|
+
} from './components/utils/editPlacement';
|
|
51
71
|
import AutoRefreshControls from './components/controls/AutoRefreshControls';
|
|
52
72
|
import AudioPlayer from './components/controls/AudioPlayer';
|
|
53
73
|
import GalleryModal from './components/modals/GalleryModal';
|
|
@@ -72,6 +92,7 @@ import AuthShell from './components/auth/AuthShell';
|
|
|
72
92
|
import AuthLoading from './components/auth/AuthLoading';
|
|
73
93
|
import AuthBrandPanel from './components/auth/AuthBrandPanel';
|
|
74
94
|
import ClientAuthFrame from './components/auth/ClientAuthFrame';
|
|
95
|
+
import ClientAuthScreen from './components/auth/ClientAuthScreen';
|
|
75
96
|
import ClientAuth from './components/auth/ClientAuth';
|
|
76
97
|
import ClientLogin from './components/auth/ClientLogin';
|
|
77
98
|
import ClientOTPVerify from './components/auth/ClientOTPVerify';
|
|
@@ -119,6 +140,12 @@ import {
|
|
|
119
140
|
/** Call Queue Components */
|
|
120
141
|
import CallQueuePop from './components/callQueue/CallQueuePop';
|
|
121
142
|
import CallQueueSettings from './components/callQueue/CallQueueSettings';
|
|
143
|
+
import CallQueueDiagnostics from './components/callQueue/CallQueueDiagnostics';
|
|
144
|
+
import {
|
|
145
|
+
CALL_POP_STATUS_EVENT,
|
|
146
|
+
getCallPopStatus,
|
|
147
|
+
updateCallPopStatus,
|
|
148
|
+
} from './components/callQueue/callPopStatus';
|
|
122
149
|
|
|
123
150
|
// Vault — the staff password manager. VaultQuickSearch mounts through
|
|
124
151
|
// Navigation's `renderers` slot; VaultManager is the page behind it.
|
|
@@ -334,8 +361,12 @@ export {
|
|
|
334
361
|
buildEnv,
|
|
335
362
|
BusinessCardOcr,
|
|
336
363
|
Call,
|
|
364
|
+
CALL_POP_STATUS_EVENT,
|
|
365
|
+
CallQueueDiagnostics,
|
|
337
366
|
CallQueuePop,
|
|
338
367
|
CallQueueSettings,
|
|
368
|
+
getCallPopStatus,
|
|
369
|
+
updateCallPopStatus,
|
|
339
370
|
cleanBase32,
|
|
340
371
|
decodeFromBlob,
|
|
341
372
|
decodeFromSource,
|
|
@@ -446,6 +477,7 @@ export {
|
|
|
446
477
|
ClientAssociationManager,
|
|
447
478
|
ClientAuth,
|
|
448
479
|
ClientAuthFrame,
|
|
480
|
+
ClientAuthScreen,
|
|
449
481
|
ClientLogin,
|
|
450
482
|
ClientOTPVerify,
|
|
451
483
|
ClientPortal,
|
|
@@ -456,11 +488,17 @@ export {
|
|
|
456
488
|
confirmDialog,
|
|
457
489
|
contactHint,
|
|
458
490
|
CustomFetch,
|
|
491
|
+
CARD_COLUMN_NAME,
|
|
492
|
+
cardRowHeight,
|
|
459
493
|
DataGrid,
|
|
460
494
|
DataGridSearch,
|
|
495
|
+
deriveCardLayout,
|
|
461
496
|
DatePickerPortal,
|
|
462
497
|
DEFAULT_AUTH_ENDPOINTS,
|
|
463
498
|
DEFAULT_CLIENT_PATHS,
|
|
499
|
+
EDIT_PLACEMENT_FLOATING,
|
|
500
|
+
EDIT_PLACEMENT_HEADER,
|
|
501
|
+
EDIT_PLACEMENTS,
|
|
464
502
|
DEFAULT_CLIENT_PROTOCOL,
|
|
465
503
|
DEFAULT_CONTACT_HINTS,
|
|
466
504
|
DENSITY_GRID,
|
|
@@ -532,6 +570,8 @@ export {
|
|
|
532
570
|
resolveAuthEndpoints,
|
|
533
571
|
resolveClientPaths,
|
|
534
572
|
resolveClientProtocol,
|
|
573
|
+
resolveEditPlacement,
|
|
574
|
+
isHeaderPlacement,
|
|
535
575
|
resolveFormVariant,
|
|
536
576
|
// Passkeys (WebAuthn). Exported so a consuming app can build its own
|
|
537
577
|
// enrolment screen against the same marshalling the login screen uses.
|
|
@@ -550,6 +590,8 @@ export {
|
|
|
550
590
|
showConfirmDialog,
|
|
551
591
|
SortableList,
|
|
552
592
|
StagePopupModal,
|
|
593
|
+
MAX_BODY_LINES,
|
|
594
|
+
StackedRow,
|
|
553
595
|
StandardModal,
|
|
554
596
|
syncCsrfFromResponse,
|
|
555
597
|
syncCsrfToken,
|