@visns-studio/visns-components 6.32.3 → 6.34.3

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 (53) hide show
  1. package/README.md +483 -0
  2. package/package.json +1 -1
  3. package/src/components/DataGrid.jsx +44 -2
  4. package/src/components/Field.jsx +32 -0
  5. package/src/components/Form.jsx +39 -2
  6. package/src/components/Navigation.jsx +274 -15
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +11 -4
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/Reset.jsx +1 -1
  13. package/src/components/auth/TwoFactorAuth.jsx +1 -1
  14. package/src/components/auth/Verify.jsx +2 -2
  15. package/src/components/auth/authEndpoints.js +43 -0
  16. package/src/components/auth/passkeyClient.js +102 -0
  17. package/src/components/auth/passkeyPrompt.js +408 -0
  18. package/src/components/auth/profileLayout.js +45 -0
  19. package/src/components/controls/DataGridSearch.jsx +16 -2
  20. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  21. package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
  22. package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
  23. package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
  24. package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
  25. package/src/components/emailCampaigns/EmailLists.jsx +505 -0
  26. package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
  27. package/src/components/generic/ActionButtons.jsx +1 -1
  28. package/src/components/generic/GenericAuth.jsx +197 -21
  29. package/src/components/generic/GenericDetail.jsx +95 -3
  30. package/src/components/generic/GenericMain.jsx +7 -0
  31. package/src/components/generic/StandardModal.jsx +71 -158
  32. package/src/components/sms/SmsCampaigns.jsx +2517 -0
  33. package/src/components/sms/SmsInbox.jsx +20 -5
  34. package/src/components/sms/SmsLineSettings.jsx +2 -5
  35. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  36. package/src/components/sms/smsEndpoints.js +23 -0
  37. package/src/components/styles/DataGrid.module.scss +22 -0
  38. package/src/components/styles/EmailCampaigns.module.scss +922 -0
  39. package/src/components/styles/Form.module.scss +100 -2
  40. package/src/components/styles/Navigation.module.scss +322 -5
  41. package/src/components/styles/Notification.module.scss +140 -8
  42. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  43. package/src/components/styles/Profile.module.scss +157 -0
  44. package/src/components/styles/Sms.module.scss +484 -4
  45. package/src/components/styles/StandardModal.module.scss +273 -0
  46. package/src/components/styles/Vault.module.scss +5 -3
  47. package/src/components/styles/global.css +38 -7
  48. package/src/components/utils/navCollapsed.js +201 -0
  49. package/src/components/utils/rowActionSettings.js +76 -0
  50. package/src/components/utils/usePasskeysEnabled.js +84 -0
  51. package/src/components/utils/useShellLayout.js +131 -0
  52. package/src/index.js +107 -1
  53. package/src/utils/rememberTab.js +70 -0
@@ -0,0 +1,84 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ /**
4
+ * WHETHER THIS APPLICATION OFFERS PASSKEYS, readable from anywhere.
5
+ *
6
+ * Exactly the arrangement `data-layout` uses (utils/useShellLayout.js), for
7
+ * exactly the same reason. `GenericAuth` takes the `passkeys` prop and can
8
+ * hand it to the screens it mounts itself — the login screen, the enrolment
9
+ * prompt — but the profile screen is NOT one of those: it is mounted by the
10
+ * consuming app's own `routeConfig`, several components below the route table,
11
+ * and the only props it is given are `userProfile` and `setUserProfile` (see
12
+ * GenericMain's `renderRoutes`).
13
+ *
14
+ * So the switch mirrors itself onto `<html>` as `data-passkeys="on"`, written
15
+ * once by GenericAuth, and the profile screen reads it. The alternative was to
16
+ * make every consuming app pass `passkeys` a second time when it builds its
17
+ * route config, which is a step every app would have to be edited for and
18
+ * would silently be skipped by the ones that were not.
19
+ *
20
+ * ABSENT MEANS OFF, which is what every app that never passed the prop gets.
21
+ */
22
+
23
+ /** The attribute GenericAuth writes on `<html>`. */
24
+ export const PASSKEYS_ATTRIBUTE = 'data-passkeys';
25
+
26
+ /** The value it is written with. */
27
+ export const PASSKEYS_ENABLED_VALUE = 'on';
28
+
29
+ /**
30
+ * Is the attribute set, right now?
31
+ *
32
+ * SSR-safe: no document means off — the same answer the first client render
33
+ * produces before GenericAuth's insertion effect has run, so the markup
34
+ * matches on hydration.
35
+ *
36
+ * @returns {boolean}
37
+ */
38
+ export const getPasskeysEnabled = () => {
39
+ if (typeof document === 'undefined' || !document.documentElement) {
40
+ return false;
41
+ }
42
+
43
+ return (
44
+ document.documentElement.getAttribute(PASSKEYS_ATTRIBUTE) ===
45
+ PASSKEYS_ENABLED_VALUE
46
+ );
47
+ };
48
+
49
+ /**
50
+ * Notify on the attribute changing.
51
+ *
52
+ * It changes once per page in a normal app, but that once can land AFTER the
53
+ * first render of a screen mounted in the same commit — a refresh straight
54
+ * onto `/profile` is the ordinary case — and a screen that read `false` then
55
+ * and never heard again would hide the tab for the life of the page.
56
+ */
57
+ const subscribe = (onChange) => {
58
+ if (
59
+ typeof document === 'undefined' ||
60
+ typeof MutationObserver === 'undefined' ||
61
+ !document.documentElement
62
+ ) {
63
+ return () => {};
64
+ }
65
+
66
+ const observer = new MutationObserver(onChange);
67
+
68
+ observer.observe(document.documentElement, {
69
+ attributes: true,
70
+ attributeFilter: [PASSKEYS_ATTRIBUTE],
71
+ });
72
+
73
+ return () => observer.disconnect();
74
+ };
75
+
76
+ /**
77
+ * Whether passkeys are on, as a subscription.
78
+ *
79
+ * @returns {boolean}
80
+ */
81
+ export const usePasskeysEnabled = () =>
82
+ useSyncExternalStore(subscribe, getPasskeysEnabled, () => false);
83
+
84
+ export default usePasskeysEnabled;
@@ -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,10 +272,25 @@ 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';
291
+ // Email campaigns — Resend Broadcasts, the screen for visns-packages'
292
+ // `email_campaigns` module. Mount it at a route and pass `canManage`.
293
+ import EmailCampaigns from './components/emailCampaigns/EmailCampaigns';
230
294
  import SmsInboxBadge from './components/sms/SmsInboxBadge';
231
295
  import SmsLineSettings from './components/sms/SmsLineSettings';
232
296
  import SmsThreadPanel from './components/sms/SmsThreadPanel';
@@ -434,10 +498,14 @@ export {
434
498
  VaultManager,
435
499
  VaultQuickSearch,
436
500
  VaultShareModal,
501
+ CAMPAIGN_STATUSES,
437
502
  clientRecipient,
438
503
  conversationCountLabel,
504
+ DEFAULT_CAMPAIGN_SETTINGS,
439
505
  DEFAULT_SMS_ENDPOINTS,
506
+ defaultCampaignName,
440
507
  describeTransport,
508
+ estimateMinutes,
441
509
  fillTemplate,
442
510
  groupMessagesByDay,
443
511
  initialsFor,
@@ -447,7 +515,11 @@ export {
447
515
  nextPlaceholder,
448
516
  normaliseNumberForDisplay,
449
517
  orderThreadsByRecency,
518
+ parsePastedRecipients,
450
519
  pickInitialThread,
520
+ readSheetRows,
521
+ recipientsFromRecords,
522
+ RECIPIENT_STATUSES,
451
523
  relativeTime,
452
524
  resolveSmsEndpoints,
453
525
  segmentCount,
@@ -455,9 +527,11 @@ export {
455
527
  SMS_LIMITS,
456
528
  SMS_MAX_SEGMENTS,
457
529
  SMS_STATUS_LABELS,
530
+ SmsCampaigns,
458
531
  SmsClientConversations,
459
532
  SmsComposeModal,
460
533
  SmsInbox,
534
+ EmailCampaigns,
461
535
  SmsInboxBadge,
462
536
  SmsLineSettings,
463
537
  SmsThreadPanel,
@@ -616,6 +690,29 @@ export {
616
690
  getPasskeyCredential,
617
691
  isPasskeyCancellation,
618
692
  isPasskeySupported,
693
+ // The enrol-once prompt, its decision, and the three requests behind it.
694
+ PasskeyEnrolPrompt,
695
+ PasskeyEnrolDialog,
696
+ DEFAULT_PASSKEY_PROMPT_COPY,
697
+ DEFAULT_PASSKEY_SNOOZE_DAYS,
698
+ PASSKEY_PROMPT_STORAGE_PREFIX,
699
+ PASSKEYS_ATTRIBUTE,
700
+ countPasskeys,
701
+ defaultPasskeyName,
702
+ deletePasskey,
703
+ enrolPasskey,
704
+ getPasskeysEnabled,
705
+ isPasskeyPromptBlocked,
706
+ listPasskeys,
707
+ neverAskPasskeyPrompt,
708
+ passkeyPromptStorageKey,
709
+ readPasskeyPromptRecord,
710
+ resolvePasskeyDestroyUrl,
711
+ resolvePasskeyOptions,
712
+ shouldPromptForPasskey,
713
+ snoozePasskeyPrompt,
714
+ usePasskeysEnabled,
715
+ writePasskeyPromptRecord,
619
716
  SectionEditor,
620
717
  SectionGroupedReport,
621
718
  SectionTypeSelector,
@@ -637,6 +734,15 @@ export {
637
734
  twoFactorSuccessTarget,
638
735
  useAvailableHeight,
639
736
  useDensity,
737
+ useShellLayout,
738
+ getShellLayout,
739
+ isSidebarShell,
740
+ SHELL_LAYOUT_ATTRIBUTE,
741
+ useNavCollapsed,
742
+ getNavCollapsed,
743
+ setNavCollapsed,
744
+ NAV_COLLAPSED_ATTRIBUTE,
745
+ NAV_COLLAPSED_STORAGE_KEY,
640
746
  SECONDARY_GRID_HEIGHT,
641
747
  VariableInserter,
642
748
  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
+