@visns-studio/visns-components 6.32.3 → 6.34.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/README.md +470 -0
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +44 -2
- package/src/components/Field.jsx +32 -0
- package/src/components/Form.jsx +39 -2
- package/src/components/Navigation.jsx +274 -15
- package/src/components/Notification.jsx +13 -2
- package/src/components/TableFilter.jsx +14 -2
- package/src/components/auth/Login.jsx +9 -2
- package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
- package/src/components/auth/Profile.jsx +422 -26
- package/src/components/auth/authEndpoints.js +43 -0
- package/src/components/auth/passkeyClient.js +102 -0
- package/src/components/auth/passkeyPrompt.js +408 -0
- package/src/components/auth/profileLayout.js +45 -0
- package/src/components/controls/DataGridSearch.jsx +16 -2
- package/src/components/controls/DataGridSortSheet.jsx +2 -0
- package/src/components/generic/ActionButtons.jsx +1 -1
- package/src/components/generic/GenericAuth.jsx +197 -21
- package/src/components/generic/GenericDetail.jsx +95 -3
- package/src/components/generic/GenericMain.jsx +7 -0
- package/src/components/generic/StandardModal.jsx +71 -158
- package/src/components/sms/SmsCampaigns.jsx +2487 -0
- package/src/components/sms/SmsInbox.jsx +18 -0
- package/src/components/sms/SmsThreadPanel.jsx +20 -0
- package/src/components/sms/smsEndpoints.js +23 -0
- package/src/components/styles/DataGrid.module.scss +22 -0
- package/src/components/styles/Form.module.scss +100 -2
- package/src/components/styles/Navigation.module.scss +322 -5
- package/src/components/styles/Notification.module.scss +140 -8
- package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
- package/src/components/styles/Profile.module.scss +157 -0
- package/src/components/styles/Sms.module.scss +451 -0
- package/src/components/styles/StandardModal.module.scss +273 -0
- package/src/components/styles/global.css +24 -5
- package/src/components/utils/navCollapsed.js +201 -0
- package/src/components/utils/rowActionSettings.js +76 -0
- package/src/components/utils/usePasskeysEnabled.js +84 -0
- package/src/components/utils/useShellLayout.js +131 -0
- package/src/index.js +103 -1
- package/src/utils/rememberTab.js +70 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WHICH SHELL THE APPLICATION IS WEARING, readable from anywhere.
|
|
5
|
+
*
|
|
6
|
+
* `GenericAuth` (and `GenericMain` below it) takes a `layout` prop with three
|
|
7
|
+
* values, and it decides the whole shape of the page:
|
|
8
|
+
*
|
|
9
|
+
* 'full' — the one-line header bar across the top, content underneath.
|
|
10
|
+
* 'cms' — a fixed sidebar rail down the left, content beside it.
|
|
11
|
+
* 'simple' — the same rail, with the simpler content frame.
|
|
12
|
+
*
|
|
13
|
+
* The prop is threaded from GenericAuth into GenericMain into Navigation, and
|
|
14
|
+
* it stops there. Anything further down the tree — a screen mounted by the
|
|
15
|
+
* route table, a panel inside that screen — is several components away from
|
|
16
|
+
* ever being told, and the answer changes how a page wants to lay itself out:
|
|
17
|
+
* a horizontal tab strip is right on a page that already has a rail down its
|
|
18
|
+
* left, and a vertical rail beside a vertical rail is not.
|
|
19
|
+
*
|
|
20
|
+
* So the shell mirrors itself onto `<html>` as `data-layout`, exactly the way
|
|
21
|
+
* the `density` prop mirrors itself as `data-density` (see GenericAuth, and
|
|
22
|
+
* `useCardRowsEnabled` in useDensity.js for the reading half). One attribute,
|
|
23
|
+
* written once by whoever owns the page, readable by CSS and by JS without a
|
|
24
|
+
* prop being threaded through six components that do not care.
|
|
25
|
+
*
|
|
26
|
+
* ABSENT MEANS 'full'. That is not a guess dressed up as a default: 'full' is
|
|
27
|
+
* GenericAuth's own default, it is what every app that never passed the prop
|
|
28
|
+
* renders, and it is what a server render — where there is no document to read
|
|
29
|
+
* — has to answer so the markup it produces matches the markup the browser
|
|
30
|
+
* produces on hydration.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The attribute GenericAuth writes on `<html>`. */
|
|
34
|
+
export const SHELL_LAYOUT_ATTRIBUTE = 'data-layout';
|
|
35
|
+
|
|
36
|
+
/** What an unset (or unrecognised) attribute means. */
|
|
37
|
+
export const DEFAULT_SHELL_LAYOUT = 'full';
|
|
38
|
+
|
|
39
|
+
/** Every layout GenericAuth accepts. */
|
|
40
|
+
export const SHELL_LAYOUTS = ['full', 'cms', 'simple'];
|
|
41
|
+
|
|
42
|
+
/** The ones that draw a navigation rail down the left edge. */
|
|
43
|
+
export const SIDEBAR_SHELL_LAYOUTS = ['cms', 'simple'];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A layout name, or `'full'` for anything that is not one.
|
|
47
|
+
*
|
|
48
|
+
* Unrecognised values are not passed through: the point of this module is that
|
|
49
|
+
* callers can branch on the answer without re-checking it, and a typo in a
|
|
50
|
+
* host's config should land on the layout every app already renders rather
|
|
51
|
+
* than on a fourth, undefined one.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} [value]
|
|
54
|
+
* @returns {'full'|'cms'|'simple'}
|
|
55
|
+
*/
|
|
56
|
+
export const normaliseShellLayout = (value) =>
|
|
57
|
+
SHELL_LAYOUTS.includes(value) ? value : DEFAULT_SHELL_LAYOUT;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Does this shell put its navigation in a rail down the left?
|
|
61
|
+
*
|
|
62
|
+
* The question almost every caller actually has — 'cms' and 'simple' differ in
|
|
63
|
+
* the content frame, not in where the navigation lives, and no layout decision
|
|
64
|
+
* in this library has ever needed to tell the two apart.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} [layout] A layout name; anything else counts as 'full'.
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export const isSidebarShell = (layout) =>
|
|
70
|
+
SIDEBAR_SHELL_LAYOUTS.includes(normaliseShellLayout(layout));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The shell layout, read off the document once.
|
|
74
|
+
*
|
|
75
|
+
* SSR-safe: no document means 'full'. Use this in an event handler or a one-off
|
|
76
|
+
* calculation; a component that has to re-render when the attribute changes
|
|
77
|
+
* wants `useShellLayout()` instead.
|
|
78
|
+
*
|
|
79
|
+
* @returns {'full'|'cms'|'simple'}
|
|
80
|
+
*/
|
|
81
|
+
export const getShellLayout = () => {
|
|
82
|
+
if (typeof document === 'undefined' || !document.documentElement) {
|
|
83
|
+
return DEFAULT_SHELL_LAYOUT;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return normaliseShellLayout(
|
|
87
|
+
document.documentElement.getAttribute(SHELL_LAYOUT_ATTRIBUTE)
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Notify on the attribute changing.
|
|
93
|
+
*
|
|
94
|
+
* It changes exactly once in a normal app — when GenericAuth's insertion
|
|
95
|
+
* effect runs, before the first paint — but that once is after the first
|
|
96
|
+
* render of anything mounted outside GenericAuth (a Storybook-style harness,
|
|
97
|
+
* a test), and a screen that read 'full' at that moment and never heard again
|
|
98
|
+
* would keep the wrong layout for the life of the page.
|
|
99
|
+
*/
|
|
100
|
+
const subscribeToShellLayout = (onChange) => {
|
|
101
|
+
if (
|
|
102
|
+
typeof document === 'undefined' ||
|
|
103
|
+
typeof MutationObserver === 'undefined' ||
|
|
104
|
+
!document.documentElement
|
|
105
|
+
) {
|
|
106
|
+
return () => {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const observer = new MutationObserver(onChange);
|
|
110
|
+
|
|
111
|
+
observer.observe(document.documentElement, {
|
|
112
|
+
attributes: true,
|
|
113
|
+
attributeFilter: [SHELL_LAYOUT_ATTRIBUTE],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return () => observer.disconnect();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The shell layout, as a subscription.
|
|
121
|
+
*
|
|
122
|
+
* @returns {'full'|'cms'|'simple'}
|
|
123
|
+
*/
|
|
124
|
+
export const useShellLayout = () =>
|
|
125
|
+
useSyncExternalStore(
|
|
126
|
+
subscribeToShellLayout,
|
|
127
|
+
getShellLayout,
|
|
128
|
+
() => DEFAULT_SHELL_LAYOUT
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
export default useShellLayout;
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,24 @@ import {
|
|
|
13
13
|
DENSITY_GRID,
|
|
14
14
|
SECONDARY_GRID_HEIGHT,
|
|
15
15
|
} from './components/utils/useDensity';
|
|
16
|
+
// Which shell the app is wearing ('full' | 'cms' | 'simple'), mirrored onto
|
|
17
|
+
// <html> by GenericAuth as `data-layout`. Screens below the route table read
|
|
18
|
+
// it from here instead of being handed the prop.
|
|
19
|
+
import {
|
|
20
|
+
getShellLayout,
|
|
21
|
+
useShellLayout,
|
|
22
|
+
isSidebarShell,
|
|
23
|
+
SHELL_LAYOUT_ATTRIBUTE,
|
|
24
|
+
} from './components/utils/useShellLayout';
|
|
25
|
+
// The collapsed-rail preference: remembered per browser, published on <html>
|
|
26
|
+
// as `data-nav-collapsed`.
|
|
27
|
+
import {
|
|
28
|
+
useNavCollapsed,
|
|
29
|
+
getNavCollapsed,
|
|
30
|
+
setNavCollapsed,
|
|
31
|
+
NAV_COLLAPSED_ATTRIBUTE,
|
|
32
|
+
NAV_COLLAPSED_STORAGE_KEY,
|
|
33
|
+
} from './components/utils/navCollapsed';
|
|
16
34
|
import { readBuildEnv, buildEnv } from './components/utils/buildEnv';
|
|
17
35
|
// Project-wide default for how record CRUD forms are presented — the side
|
|
18
36
|
// sheet unless an app calls setDefaultFormLayout('modal') at bootstrap. A
|
|
@@ -93,6 +111,11 @@ export * from './components/columns/ColumnRenderers.jsx';
|
|
|
93
111
|
/** Auth Specific Components */
|
|
94
112
|
import Login from './components/auth/Login';
|
|
95
113
|
import Profile from './components/auth/Profile';
|
|
114
|
+
// The enrol-once prompt. Rendered by GenericAuth when `passkeys` is on;
|
|
115
|
+
// exported for a host with its own shell, or its own idea of when to ask.
|
|
116
|
+
import PasskeyEnrolPrompt, {
|
|
117
|
+
PasskeyEnrolDialog,
|
|
118
|
+
} from './components/auth/PasskeyEnrolPrompt';
|
|
96
119
|
import Reset from './components/auth/Reset';
|
|
97
120
|
import Verify from './components/auth/Verify';
|
|
98
121
|
import TwoFactorAuth from './components/auth/TwoFactorAuth';
|
|
@@ -113,6 +136,7 @@ import {
|
|
|
113
136
|
DEFAULT_CLIENT_PATHS,
|
|
114
137
|
resolveAuthEndpoints,
|
|
115
138
|
resolveClientPaths,
|
|
139
|
+
resolvePasskeyDestroyUrl,
|
|
116
140
|
} from './components/auth/authEndpoints';
|
|
117
141
|
import {
|
|
118
142
|
DEFAULT_CONTACT_HINTS,
|
|
@@ -138,6 +162,31 @@ import {
|
|
|
138
162
|
isPasskeyCancellation,
|
|
139
163
|
isPasskeySupported,
|
|
140
164
|
} from './components/auth/webauthn';
|
|
165
|
+
import {
|
|
166
|
+
DEFAULT_PASSKEY_PROMPT_COPY,
|
|
167
|
+
DEFAULT_PASSKEY_SNOOZE_DAYS,
|
|
168
|
+
PASSKEY_PROMPT_STORAGE_PREFIX,
|
|
169
|
+
countPasskeys,
|
|
170
|
+
defaultPasskeyName,
|
|
171
|
+
isPasskeyPromptBlocked,
|
|
172
|
+
neverAskPasskeyPrompt,
|
|
173
|
+
passkeyPromptStorageKey,
|
|
174
|
+
readPasskeyPromptRecord,
|
|
175
|
+
resolvePasskeyOptions,
|
|
176
|
+
shouldPromptForPasskey,
|
|
177
|
+
snoozePasskeyPrompt,
|
|
178
|
+
writePasskeyPromptRecord,
|
|
179
|
+
} from './components/auth/passkeyPrompt';
|
|
180
|
+
import {
|
|
181
|
+
deletePasskey,
|
|
182
|
+
enrolPasskey,
|
|
183
|
+
listPasskeys,
|
|
184
|
+
} from './components/auth/passkeyClient';
|
|
185
|
+
import {
|
|
186
|
+
PASSKEYS_ATTRIBUTE,
|
|
187
|
+
getPasskeysEnabled,
|
|
188
|
+
usePasskeysEnabled,
|
|
189
|
+
} from './components/utils/usePasskeysEnabled';
|
|
141
190
|
import {
|
|
142
191
|
CLIENT_AUTH_PROTOCOLS,
|
|
143
192
|
DEFAULT_CLIENT_PROTOCOL,
|
|
@@ -223,7 +272,19 @@ import {
|
|
|
223
272
|
|
|
224
273
|
// Messaging — the SMS inbox. SmsInboxBadge mounts through Navigation's
|
|
225
274
|
// `renderers` slot; SmsInbox is the page behind it, SmsThreadPanel is the
|
|
226
|
-
// conversation on its own for embedding elsewhere.
|
|
275
|
+
// conversation on its own for embedding elsewhere. SmsCampaigns is the bulk
|
|
276
|
+
// side of the same module — one message to an imported list of numbers — and
|
|
277
|
+
// brings the opt-out register with it.
|
|
278
|
+
import SmsCampaigns, {
|
|
279
|
+
CAMPAIGN_STATUSES,
|
|
280
|
+
DEFAULT_CAMPAIGN_SETTINGS,
|
|
281
|
+
RECIPIENT_STATUSES,
|
|
282
|
+
defaultCampaignName,
|
|
283
|
+
estimateMinutes,
|
|
284
|
+
parsePastedRecipients,
|
|
285
|
+
readSheetRows,
|
|
286
|
+
recipientsFromRecords,
|
|
287
|
+
} from './components/sms/SmsCampaigns';
|
|
227
288
|
import SmsClientConversations from './components/sms/SmsClientConversations';
|
|
228
289
|
import SmsComposeModal from './components/sms/SmsComposeModal';
|
|
229
290
|
import SmsInbox from './components/sms/SmsInbox';
|
|
@@ -434,10 +495,14 @@ export {
|
|
|
434
495
|
VaultManager,
|
|
435
496
|
VaultQuickSearch,
|
|
436
497
|
VaultShareModal,
|
|
498
|
+
CAMPAIGN_STATUSES,
|
|
437
499
|
clientRecipient,
|
|
438
500
|
conversationCountLabel,
|
|
501
|
+
DEFAULT_CAMPAIGN_SETTINGS,
|
|
439
502
|
DEFAULT_SMS_ENDPOINTS,
|
|
503
|
+
defaultCampaignName,
|
|
440
504
|
describeTransport,
|
|
505
|
+
estimateMinutes,
|
|
441
506
|
fillTemplate,
|
|
442
507
|
groupMessagesByDay,
|
|
443
508
|
initialsFor,
|
|
@@ -447,7 +512,11 @@ export {
|
|
|
447
512
|
nextPlaceholder,
|
|
448
513
|
normaliseNumberForDisplay,
|
|
449
514
|
orderThreadsByRecency,
|
|
515
|
+
parsePastedRecipients,
|
|
450
516
|
pickInitialThread,
|
|
517
|
+
readSheetRows,
|
|
518
|
+
recipientsFromRecords,
|
|
519
|
+
RECIPIENT_STATUSES,
|
|
451
520
|
relativeTime,
|
|
452
521
|
resolveSmsEndpoints,
|
|
453
522
|
segmentCount,
|
|
@@ -455,6 +524,7 @@ export {
|
|
|
455
524
|
SMS_LIMITS,
|
|
456
525
|
SMS_MAX_SEGMENTS,
|
|
457
526
|
SMS_STATUS_LABELS,
|
|
527
|
+
SmsCampaigns,
|
|
458
528
|
SmsClientConversations,
|
|
459
529
|
SmsComposeModal,
|
|
460
530
|
SmsInbox,
|
|
@@ -616,6 +686,29 @@ export {
|
|
|
616
686
|
getPasskeyCredential,
|
|
617
687
|
isPasskeyCancellation,
|
|
618
688
|
isPasskeySupported,
|
|
689
|
+
// The enrol-once prompt, its decision, and the three requests behind it.
|
|
690
|
+
PasskeyEnrolPrompt,
|
|
691
|
+
PasskeyEnrolDialog,
|
|
692
|
+
DEFAULT_PASSKEY_PROMPT_COPY,
|
|
693
|
+
DEFAULT_PASSKEY_SNOOZE_DAYS,
|
|
694
|
+
PASSKEY_PROMPT_STORAGE_PREFIX,
|
|
695
|
+
PASSKEYS_ATTRIBUTE,
|
|
696
|
+
countPasskeys,
|
|
697
|
+
defaultPasskeyName,
|
|
698
|
+
deletePasskey,
|
|
699
|
+
enrolPasskey,
|
|
700
|
+
getPasskeysEnabled,
|
|
701
|
+
isPasskeyPromptBlocked,
|
|
702
|
+
listPasskeys,
|
|
703
|
+
neverAskPasskeyPrompt,
|
|
704
|
+
passkeyPromptStorageKey,
|
|
705
|
+
readPasskeyPromptRecord,
|
|
706
|
+
resolvePasskeyDestroyUrl,
|
|
707
|
+
resolvePasskeyOptions,
|
|
708
|
+
shouldPromptForPasskey,
|
|
709
|
+
snoozePasskeyPrompt,
|
|
710
|
+
usePasskeysEnabled,
|
|
711
|
+
writePasskeyPromptRecord,
|
|
619
712
|
SectionEditor,
|
|
620
713
|
SectionGroupedReport,
|
|
621
714
|
SectionTypeSelector,
|
|
@@ -637,6 +730,15 @@ export {
|
|
|
637
730
|
twoFactorSuccessTarget,
|
|
638
731
|
useAvailableHeight,
|
|
639
732
|
useDensity,
|
|
733
|
+
useShellLayout,
|
|
734
|
+
getShellLayout,
|
|
735
|
+
isSidebarShell,
|
|
736
|
+
SHELL_LAYOUT_ATTRIBUTE,
|
|
737
|
+
useNavCollapsed,
|
|
738
|
+
getNavCollapsed,
|
|
739
|
+
setNavCollapsed,
|
|
740
|
+
NAV_COLLAPSED_ATTRIBUTE,
|
|
741
|
+
NAV_COLLAPSED_STORAGE_KEY,
|
|
640
742
|
SECONDARY_GRID_HEIGHT,
|
|
641
743
|
VariableInserter,
|
|
642
744
|
verifyContactFor,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open tab of a GenericDetail page, remembered in the address bar as
|
|
3
|
+
* `?tab=<filter id>`. Two pure-ish helpers over `window.location` and
|
|
4
|
+
* `window.history`, kept out of the component so `node --test` can drive them
|
|
5
|
+
* with a fake window — GenericDetail itself needs a DOM, a router and a
|
|
6
|
+
* server to mount.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The query parameter the open tab is remembered in. */
|
|
10
|
+
export const TAB_PARAM = 'tab';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Apply `?tab=<id>` to a freshly built tab list: the named top-level filter is
|
|
14
|
+
* shown and the rest are not. Returns the list untouched when the parameter
|
|
15
|
+
* is absent, names no top-level filter, or already names the shown one.
|
|
16
|
+
*
|
|
17
|
+
* The active class is borrowed from whichever entry the config marks shown
|
|
18
|
+
* (`subactive` in most view configs, `activetab` on a simple strip) so the
|
|
19
|
+
* strip is styled exactly as a click would style it; children, where a tab
|
|
20
|
+
* has them, are left as the config wrote them — the first child is the one a
|
|
21
|
+
* click on the parent would open too.
|
|
22
|
+
*/
|
|
23
|
+
export const openTabFromUrl = (list) => {
|
|
24
|
+
let wanted = null;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
wanted = new URLSearchParams(window.location.search).get(TAB_PARAM);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return list;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!wanted || !Array.isArray(list)) return list;
|
|
33
|
+
|
|
34
|
+
const target = list.find((f) => f && String(f.id) === wanted);
|
|
35
|
+
|
|
36
|
+
if (!target || target.show) return list;
|
|
37
|
+
|
|
38
|
+
const shown = list.find((f) => f && f.show);
|
|
39
|
+
const activeClass = (shown && shown.class) || 'subactive';
|
|
40
|
+
|
|
41
|
+
return list.map((f) => {
|
|
42
|
+
const on = f === target;
|
|
43
|
+
|
|
44
|
+
return { ...f, show: on, active: on, class: on ? activeClass : '' };
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Put the shown top-level tab into the address bar as `?tab=<id>`, keeping
|
|
50
|
+
* every other query parameter and the hash, through `replaceState` so no
|
|
51
|
+
* history entry is added and the router is not involved. A list with nothing
|
|
52
|
+
* shown writes nothing.
|
|
53
|
+
*/
|
|
54
|
+
export const writeTabToUrl = (list) => {
|
|
55
|
+
const shown = list.find((f) => f && f.show && f.id !== undefined && f.id !== null);
|
|
56
|
+
|
|
57
|
+
if (!shown) return;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(window.location.href);
|
|
61
|
+
|
|
62
|
+
if (url.searchParams.get(TAB_PARAM) === String(shown.id)) return;
|
|
63
|
+
|
|
64
|
+
url.searchParams.set(TAB_PARAM, String(shown.id));
|
|
65
|
+
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
/* A sandbox with no history object — nothing to remember into. */
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|