@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
package/README.md CHANGED
@@ -9,6 +9,335 @@ A comprehensive React component library used by the VISNS Studio team for CRM an
9
9
 
10
10
  VISNS Components is a React-based UI component library that provides a set of reusable, consistent, and customizable components for building web applications. It includes components for authentication, data grids, forms, navigation, and more, designed to work seamlessly together.
11
11
 
12
+ ## Recent Updates (v6.34.3)
13
+
14
+ ### The auth fields' values no longer sit on top of their labels
15
+
16
+ The sign-in, reset, verify and 2FA screens draw a floating label inside the
17
+ input, and `_authShell.scss` sizes those inputs for it — a 3.35rem box with a
18
+ top-heavy padding so the value clears the label. That rule is `.field input`,
19
+ (0,1,1), and `global.css` sizes every text input through a twenty-clause
20
+ `input[type]:not(...)` chain that outranks it. So the chain won: the box shrank
21
+ to `--field-height`, the padding went horizontal-only, and the value was drawn
22
+ straight through the label. The six auth inputs now carry `visns-auth-input`,
23
+ and both global chains (sizing and focus) exclude it, the same opt-out
24
+ `searchInput` and `filterInput` already have. Apps that keep a global input
25
+ rule of their own can exclude the same class.
26
+
27
+ ### The shell layout is on `<html>`, where anything can read it
28
+
29
+ `GenericAuth`'s `layout` prop — `full` for the header bar, `cms` or `simple`
30
+ for the sidebar rail — decided the shape of the whole page and then stopped at
31
+ `Navigation`. Everything the route table mounts is several components away from
32
+ ever being told, and the answer changes how a screen wants to lay itself out: a
33
+ horizontal tab strip is right on a page that already has a rail down its left,
34
+ and a vertical rail beside a vertical rail is not.
35
+
36
+ So the shell now mirrors itself onto `<html>` as `data-layout="full|cms|simple"`,
37
+ exactly the way `density` mirrors itself as `data-density` — one attribute,
38
+ written once by whoever owns the page (and removed on unmount), readable by CSS
39
+ and by JS without a prop being threaded through six components that do not care.
40
+ `getShellLayout()` reads it once, `useShellLayout()` subscribes to it, and
41
+ `isSidebarShell(layout)` answers the question callers actually have; all three
42
+ are exported from the package, and all three answer `'full'` where there is no
43
+ document, which is both the SSR answer and what every app that never passed the
44
+ prop renders. Nothing in the library styles off the attribute, so an existing
45
+ app gains one attribute on `<html>` and changes in no other way.
46
+
47
+ ### The notification panel opens beside the rail, not off the bottom of it
48
+
49
+ With `navigation.actionsPlacement: "sidebar"`, Notifications is a row pinned to
50
+ the FOOT of the rail. The panel is `position: absolute; top: 100%; right: 0` of
51
+ its trigger — which is a panel hanging off the bottom-right corner of a header
52
+ chip, and from a row at the bottom-left of the viewport that is a panel dropping
53
+ off the screen and lying half on the rail, half on the page.
54
+
55
+ Inside `[data-nav-actions="sidebar"]` it now anchors to the row's right edge and
56
+ its bottom (`left: calc(100% + var(--nav-action-panel-gap, 10px)); bottom: 0`),
57
+ clearing the rail's own `z-index: 996`, so it floats over the content the way a
58
+ rail's flyout is expected to. The box is a column capped at `min(70vh, 640px)`
59
+ with the notification list as the only part that scrolls, so the header, the
60
+ Desktop alerts row and the footer button stay put however long the list is, and
61
+ it wears the radius and shadow the rail's rows wear. "View all notifications"
62
+ was mid-grey on a grey band — the colour of a disabled control, for the one
63
+ action in the panel — and is now the primary button on the project's own
64
+ `--primary-color`. Below 1024px, where the rail is a drawer and there is nothing
65
+ to its right, the panel opens upward from the row at the drawer's full width.
66
+
67
+ Every rule is scoped under the sidebar selector: a `layout="full"` app renders
68
+ the same panel it rendered before, byte for byte.
69
+
70
+ ### The rail's icons were never drawn
71
+
72
+ `renderNav` has always resolved an icon for a sidebar row from `NAV_ICONS` by
73
+ the item's `icon` or, failing that, its `id` — so `dashboard`, `clients`,
74
+ `reports` and `settings` get one with nothing in the config saying so. None of
75
+ them ever appeared, in either state, and once the rail could collapse that left
76
+ a column of blank squares.
77
+
78
+ The cause was one rule written for the top bar: `.app-nav .nav-item { svg {
79
+ display: none; visibility: hidden } }`. `.app-nav` is the list class BOTH
80
+ layouts use, so the bar's decision — a row of words, an icon per word would be
81
+ noise — was silently taken for the rail as well. It was not a fight the rail
82
+ could lose on specificity either: the rail's own svg rule is five classes deep
83
+ and outranks it comfortably, but it only ever set `position`, `right`, `opacity`
84
+ and `color`. It never mentioned `display`, so there was nothing for the higher
85
+ specificity to override.
86
+
87
+ The two hiding declarations now sit under `.hwrap`, the header layout's own
88
+ root, which the sidebar layouts never match. The rail's rule states
89
+ `display: block; visibility: visible; flex: 0 0 auto` rather than assuming them,
90
+ the row carries `gap: 0.6rem` between icon and label, and the old `right: 8px`
91
+ nudge — which made sense while the icon was hidden and now just pulled the glyph
92
+ into the row's padding — is gone. Collapsed, the row centres what is left.
93
+
94
+ ### Row actions can be disabled, not just hidden
95
+
96
+ A row action could only be taken away per row, with `condition: (row) =>
97
+ boolean`. That is right when the action does not apply to the row, and wrong
98
+ when it applies but is not permitted: an icon present on nine rows and missing
99
+ on the tenth reads as a rendering bug, and it answers none of the question the
100
+ user has, which is "why not this one".
101
+
102
+ `disabled: (row) => boolean | string` keeps the icon in the cell and makes it
103
+ inert. A string both disables the action and becomes its tooltip, replacing the
104
+ action's ordinary title — the cell is one 18px glyph and there is nowhere else
105
+ for a reason to go:
106
+
107
+ ```js
108
+ {
109
+ id: 'update',
110
+ disabled: (row) =>
111
+ row.name === 'Administrator'
112
+ ? 'The Administrator role cannot be changed.'
113
+ : false,
114
+ }
115
+ ```
116
+
117
+ It applies to every action id at once, resolved where the icon component is
118
+ produced rather than in each of the two dozen `case` branches. The icon is drawn
119
+ at 38% opacity with `cursor: not-allowed` (`.vs-action--disabled`), carries
120
+ `aria-disabled="true"` and `tabIndex={-1}`, and swallows its click — dropping
121
+ the handler entirely would let the event reach the row underneath, which on most
122
+ grids opens the record, so "greyed out" would still navigate. Pointer events are
123
+ deliberately left on, or the tooltip explaining the whole thing could never be
124
+ hovered.
125
+
126
+ `condition` still hides, both may be set, and a hidden action is never asked
127
+ whether it is disabled. **A throwing predicate is NOT disabled** — the opposite
128
+ of `condition`, which hides on a throw: failing closed there costs a verb the
129
+ user might not have had anyway, while failing closed here would disable a
130
+ legitimate action on every row of the grid on one bad field access. A literal
131
+ (`disabled: true`, or a string) is accepted as well as a predicate.
132
+
133
+ ### Dialogs on a phone
134
+
135
+ `StandardModal` decided its own geometry in JavaScript, per render, from
136
+ `window.innerWidth` and a user-agent sniff. Four things were wrong with that at
137
+ phone size, and the first three are why a long form was hard to finish there.
138
+
139
+ **Save was off the bottom of the screen.** The panel scrolled *and*
140
+ `.modal__content` inside it scrolled, both capped at `85vh`. The form's button
141
+ bar is `position: sticky; bottom: 0`, so it stuck to the bottom of the *inner*
142
+ box — which starts below the header and therefore ends below the bottom of the
143
+ outer one. Reaching the end of the form did not reach Save; you had to scroll
144
+ the outer region as well. A dialog is one scroll region now: `.modal__content`
145
+ gives up its cap and its overflow, the panel is the scroller, the header is
146
+ sticky to the top of it and the button bar to the bottom, and both stay on
147
+ screen at every height.
148
+
149
+ **`100vh` is not the screen on iOS.** A side sheet was `height: 100vh`, which
150
+ Safari measures as though the address bar were hidden — so the last ~60px of
151
+ the panel, the part with the buttons in it, sat underneath the browser chrome.
152
+ Every height is stated twice now, `vh` then `dvh`, which an inline style object
153
+ cannot do and a stylesheet can.
154
+
155
+ **A phone-shaped window was often not "mobile".** The sniff required
156
+ `isMobileUA || (width <= 768 && hasTouch)`, so a 390px viewport that does not
157
+ advertise touch — responsive mode, a narrow window, some webviews — took the
158
+ desktop branch and its `min-width: 600px`, inside 390px of glass, with the page
159
+ scrolling sideways. Width is the only question now, and it is asked in a media
160
+ query: at ≤640px every variant is full-width with the minimums off, the centred
161
+ dialog lands as a **bottom sheet** (rounded at the top, pinned to the bottom
162
+ edge, `92dvh`, clear of `env(safe-area-inset-bottom)`), and the side sheet fills
163
+ the screen with no slide-in, because it has no edge to arrive from. The band
164
+ between 641 and 1024px is fixed too: `large` asked for 800px plus the overlay's
165
+ own padding, so it overflowed every window narrower than 840px.
166
+
167
+ **The dialog was underneath the app.** The overlay was `z-index: 1000` and the
168
+ sidebar rail is 1050, so on a `cms` layout the rail painted over the dialog's
169
+ left edge. The order is now written down in one place
170
+ (`StandardModal.module.scss`) and it runs: content header 995, rail actions 996,
171
+ rail 1050, **overlay 1200** (`--modal-z`), **date pickers 1300**
172
+ (`--datepicker-z`), portalled react-select menus 9999. The two upper entries
173
+ matter as much as the lower ones — a date picker and a select menu opened from
174
+ inside a form render into `<body>`, outside the overlay's stacking context, so
175
+ each needs a number above it or it opens *behind* the form. The pickers were
176
+ 1060, a number chosen years ago to clear "Bootstrap modals (1050)", and
177
+ `.datepicker-portal-container` had no rule at all.
178
+
179
+ Also: page scroll is locked to the value it found rather than to `unset` (which
180
+ used to release the navigation drawer's own lock), `overscroll-behavior` stops a
181
+ scroll that reaches the end of the panel continuing into the page behind it,
182
+ form controls inside a dialog are 16px at `(max-width: 640px) and (pointer:
183
+ coarse)` so iOS does not zoom into them on focus, and the close button is a
184
+ 44px target there. `size: "half"` fields already collapsed to one column — the
185
+ form grid is a container query, and a full-width phone panel is always narrow
186
+ enough. `closeOnDocumentClick`, Escape, the focus trap and focus return are
187
+ unchanged, and `customStyles` is still inline and still wins over all of it.
188
+
189
+ ### The rail collapses, and the tooltips become the labels
190
+
191
+ The sidebar rail costs `--sidebar-width` of every page, permanently, to show a
192
+ handful of words. Next to a datagrid that is a column of the table. There is now
193
+ a collapse toggle on the rail's logo plate — a `PanelLeftClose` / `PanelLeftOpen`
194
+ chevron, absolutely positioned against the plate so an expanded rail is laid out
195
+ exactly as it was — and the rail folds down to an icon strip, which is the state
196
+ the tooltips above were written for: rows draw their icon alone, the label is
197
+ clipped to a pixel rather than removed (so it stays the accessible name), and an
198
+ item with no icon shows the first letter of its label in a small circle instead
199
+ of collapsing to an empty square. An expanded nav row is byte-identical to
200
+ 6.32.3, wrapper included — the label is only put in an element once the rail is
201
+ collapsed, because this rail's stylesheet styles `span` as a descendant of
202
+ `.nav-item` (a rule that belongs to the group heading) and would otherwise draw
203
+ every row as a `--primary-color` block.
204
+
205
+ - **Prop**: `collapsedLogo` on `GenericAuth` (threaded to `GenericMain` and
206
+ `Navigation`) — the square brand mark for the strip. Optional: with none, the
207
+ plate holds the toggle alone, which reads better than a wordmark cropped to
208
+ two letters.
209
+ - **Storage**: `localStorage['visns.nav.collapsed']`, read inside a `try` (a
210
+ private window that throws on access is simply expanded) and read
211
+ **synchronously in a lazy `useState` initialiser**, so a remembered collapse
212
+ is in place for the first paint instead of snapping narrow after it.
213
+ - **Attribute**: `<html data-nav-collapsed="true">`, written by `Navigation` in
214
+ a `useInsertionEffect`, the same mechanism as `data-layout` and `data-density`.
215
+ Absent means expanded, so an app whose toggle has never been clicked matches
216
+ none of the new rules.
217
+ - **CSS variables**: collapsed, the root redefines
218
+ `--sidebar-width: var(--sidebar-collapsed-width, 64px)`. That one lever moves
219
+ the rail's width, the content's `margin-left` and everything else already
220
+ sized against the variable; an app that retuned `--sidebar-width` keeps its own
221
+ expanded figure. The width animates over `var(--speed, .18s)`.
222
+ - **Helpers**: `useNavCollapsed()`, `getNavCollapsed()` and `setNavCollapsed()`
223
+ are exported, for an app that wants its own shortcut or a focus-mode button.
224
+
225
+ Groups with children expand the rail rather than trying to open a two-level menu
226
+ inside a 64px strip — the simplest behaviour that cannot be mispositioned. Below
227
+ 1024px the rail is the mobile drawer, where there is no width to reclaim: the
228
+ toggle is not rendered there, the attribute is not applied, and every collapsed
229
+ rule sits inside `min-width: 1025px`, so drawer behaviour is exactly as it was.
230
+ `layout="full"` has no rail and is untouched.
231
+
232
+ ### Every row in the rail has a tooltip, not just the bell
233
+
234
+ The bell carried one because it is a mounted component that brought its own; the
235
+ navigation rows and the other account rows — Profile, Logout — had nothing. They
236
+ all now carry the same `data-tooltip-id="system-tooltip"` / `data-tooltip-content`
237
+ pair every header chip has always had, read by the single `<Tooltip>` GenericAuth
238
+ mounts, with the label taken from the item's own `label` and falling back to the
239
+ humanised id. They are drawn **only while the rail is collapsed** (see below) —
240
+ an icon strip is unreadable without them, and a tooltip repeating a label printed
241
+ six pixels to its right is noise; the attributes are omitted rather than the
242
+ tooltip disabled. `place="right"`, not the header's `"bottom"`: a tooltip under a
243
+ row in a vertical list covers the next row, which is the one you were about to
244
+ read.
245
+ No `title` attribute goes anywhere near them — the native tooltip and this one
246
+ both fire, half a second apart, and you get the label twice in two boxes.
247
+ `Notification` takes a `tooltipPlace` prop for the same reason, defaulting to
248
+ the `"bottom"` it has always used. Header-layout chips are untouched.
249
+
250
+ ### Profile tabs follow the shell
251
+
252
+ The account screen drew a "My Profile" header card and then a second card
253
+ holding a two-item rail beside the content — on a `cms` app, a vertical
254
+ navigation column immediately beside the app's own vertical navigation column,
255
+ spending a fifth of the content width to show two words, with "Overview" printed
256
+ twice half a centimetre apart.
257
+
258
+ `Profile` takes a `tabLayout` prop now, `'rail' | 'underline'`, the same two
259
+ names and the same `<TableFilter variant="underline">` strip that `GenericIndex`
260
+ and `GenericDetail` use. **The default is resolved from the shell layout above**:
261
+ `underline` when the app is `cms` or `simple`, `rail` when it is `full`. So a
262
+ sidebar app gets the strip with no host edit, a top-bar app keeps the rail it has
263
+ always had, and a page that knows better names the prop and overrides both. In
264
+ strip mode the title is the page's own title row rather than a card, the tabs sit
265
+ directly on one content card, and the panel header — which only ever repeated the
266
+ selected tab's name — is not drawn. Rail mode renders the markup and the styles
267
+ it always did.
268
+
269
+ ### Passkeys: asked once, on the device that can use them
270
+
271
+ The login screen has had a "Sign in with a passkey" button since 6.32 — for the
272
+ people who had already set one up somewhere else. Nobody had, because nothing
273
+ ever offered. `GenericAuth` now makes the offer itself, once, immediately after
274
+ a sign-in, and gives the account screen a tab for managing what was enrolled.
275
+
276
+ `passkeys` grows from a boolean to `boolean | object`:
277
+
278
+ ```jsx
279
+ <GenericAuth
280
+ passkeys={{
281
+ enabled: true, // default true — an object IS the opt-in
282
+ promptOnLogin: true, // default true; false = offer, never interrupt
283
+ snoozeDays: 30, // how long "Not now" holds for
284
+ copy: { title: 'Sign in faster next time' },
285
+ }}
286
+ />
287
+ ```
288
+
289
+ `true` keeps everything it meant (the login button) and adds the prompt and the
290
+ tab. **`false` or absent renders nothing at all** — no button, no tab, no
291
+ prompt, no request — so an app that never passed the prop is unchanged.
292
+
293
+ The dialog is offered only when every one of these holds, and the decision is
294
+ one pure function (`shouldPromptForPasskey` in `auth/passkeyPrompt.js`) with a
295
+ test per gate:
296
+
297
+ - the host enabled passkeys and left `promptOnLogin` on;
298
+ - `isPasskeySupported()` — the browser can actually do WebAuthn;
299
+ - **somebody signed in during this page's life**, rather than refreshing a page
300
+ whose session already existed. `GenericAuth` hands the auth screens a
301
+ `markAuthenticated` wrapper instead of its raw `setIsAuthenticated`, so a
302
+ sign-in from `Login`/`TwoFactorAuth` is distinguishable from the profile check
303
+ that finds an existing session. Nothing is written to storage for this: a flag
304
+ in `sessionStorage` outlives the page that wrote it, and this value cannot;
305
+ - they did not sign in **with** a passkey — `Login` reports
306
+ `setSystemAuth(true, { method: 'passkey' })`, and that person is never asked;
307
+ - they hold no passkeys yet, known from a GET of the index endpoint. A 404, a
308
+ failure or an unreadable answer means *don't prompt* — never *prompt*;
309
+ - they have not dismissed it on this device.
310
+
311
+ The dismissal lives in `localStorage` under **`visns.passkeys.prompt.<userId>`**
312
+ as `{dismissedAt, never}` — per user, because two accounts sharing a browser are
313
+ two answers. "Not now" (and the X, the backdrop, and cancelling the browser's own
314
+ sheet) writes a snooze; "Don't ask again" writes `never: true`. Every read and
315
+ write is in a try/catch, so a browser with site data blocked degrades to "not
316
+ remembered" rather than to a thrown error.
317
+
318
+ Endpoints are the `config('visns-packages.passkeys.uris')` defaults, overridable
319
+ one at a time through the existing `endpoints` prop, exactly like the sign-in
320
+ pair:
321
+
322
+ | key | default |
323
+ | --- | --- |
324
+ | `passkeyIndex` | `/ajax/passkeys` |
325
+ | `passkeyRegisterOptions` | `/ajax/passkeys/options` |
326
+ | `passkeyRegister` | `/ajax/passkeys/register` |
327
+ | `passkeyDestroy` | `/ajax/passkeys/{id}` |
328
+
329
+ The ceremony itself — options, `createPasskeyCredential`, store — is one shared
330
+ `enrolPasskey(endpoints, { name })` in `auth/passkeyClient.js`, called by both
331
+ the prompt and the profile tab so the two cannot drift.
332
+
333
+ **The profile's Passkeys tab** (list, add, remove) appears whenever the feature
334
+ is on, with no second prop to pass: `GenericAuth` mirrors the switch onto
335
+ `<html data-passkeys="on">` and `Profile` reads it, the same arrangement
336
+ `data-layout` uses — necessary because `Profile` is mounted by the consuming
337
+ app's own `routeConfig` and is never handed `GenericAuth`'s props. `/profile/passkeys`
338
+ and `?tab=passkeys` deep-link to it. A host can still force it either way with
339
+ `<Profile passkeys={true|false} />`.
340
+
12
341
  ## Recent Updates (v6.32.3)
13
342
 
14
343
  ### The nav dropdowns open where the pointer left them
@@ -102,6 +431,32 @@ embedded rows already laid the same node out as a flex row with a 0.75rem gap;
102
431
  the `.crmtitle` / `.crmtitleSimple` bar now does the same (wrapping when the
103
432
  bar is narrow). CSS only; no markup or config change.
104
433
 
434
+ ### Also in this release, merged from `main`
435
+
436
+ Five releases landed on `main` while this line was being built and are part
437
+ of 6.34.0 as well:
438
+
439
+ - **`SmsCampaigns`** — see below.
440
+ - `SmsThreadPanel`: the opted-out note says Zoom enforces the block until the
441
+ number texts START.
442
+ - `SmsCampaigns`: "Open conversation" on a campaign recipient navigates through
443
+ the router instead of a full reload.
444
+ - `GenericDetail`: the open tab is remembered in the address bar as `?tab=`
445
+ (`src/utils/rememberTab.js`).
446
+ - Every button says what it does on hover — action buttons carry a `title`.
447
+
448
+ ### `SmsCampaigns` — bulk sends from an imported list
449
+
450
+ One screen for a mailshot: paste names and mobiles or drop a CSV/XLSX (parsed
451
+ in the browser), pick the line, type one message with `{first_name}` /
452
+ `{name}` / `{<column>}` chips beside a server-rendered preview and a segment
453
+ count, review the server's report (invalid rows, duplicates, opted-out
454
+ numbers), then start. A per-campaign view shows progress with pause, cancel
455
+ and retry-failed, and an opt-out manager lists the numbers that texted STOP.
456
+ `SmsInbox` gains `campaignsUrl`; `SmsThreadPanel` prints a muted line when a
457
+ thread's number has opted out, disabling nothing. Ten routes added to
458
+ `makeSmsEndpoints`.
459
+
105
460
  ## Recent Updates (v6.31.2)
106
461
 
107
462
  ### `MergeEntity` compares the two records it is actually merging
@@ -854,6 +1209,134 @@ and `.sms.updated`, both carrying `{thread, message}`.
854
1209
  `message` (MessageSquare) and `messages` (MessagesSquare) were added to
855
1210
  `SETTING_ICONS`.
856
1211
 
1212
+ #### `SmsCampaigns` — bulk SMS on an imported list (6.31.0)
1213
+
1214
+ The other half of the messaging module: one message to a list of numbers, sent
1215
+ one at a time down a line at the rate the server allows. The inbox is a
1216
+ conversation with a person; this is a send to a spreadsheet, and everything it
1217
+ does differently follows from that.
1218
+
1219
+ | Prop | Default | Notes |
1220
+ | --- | --- | --- |
1221
+ | `endpoints` | `DEFAULT_SMS_ENDPOINTS` | Merged over the defaults, exactly as everywhere else in the module. |
1222
+ | `userProfile` | `null` | Accepted for consistency; unread. |
1223
+ | `canManage` | `true` | False leaves the list, a campaign's page and the opt-out register readable and takes away New campaign, every action button and the opt-out Add/Remove. |
1224
+ | `pageTitle` | `'SMS campaigns'` | |
1225
+ | `inboxUrl` | `'/messages'` | A sent recipient links to `` `${inboxUrl}?thread=<id>` `` — the same deep link `SmsInbox` reads. |
1226
+ | `pollMs` | `5000` | The list polls only while some campaign is `sending`; a campaign's own page polls only while it is. `0` turns both off. |
1227
+
1228
+ Three views in one component, switched by local state. Only the detail view is
1229
+ worth a link, so it is the only one that writes to the address bar —
1230
+ `?campaign=<id>`, read once on mount and kept in step with
1231
+ `history.replaceState`, the arrangement `SmsInbox` makes with `?thread=`. The
1232
+ wizard's step is deliberately NOT in the URL: a half-built campaign is not
1233
+ something a link could restore, so a `?step=2` anybody could paste would open
1234
+ on an empty recipient list.
1235
+
1236
+ **Step one takes a list two ways**, side by side and of equal weight. A `.csv`
1237
+ or `.xlsx` is parsed **in the browser** — `xlsx`, imported lazily on the first
1238
+ drop the way `qrDecode.js` imports jsQR — and then two selects say which column
1239
+ is the name and which the mobile, defaulted by header heuristics and re-derived
1240
+ as they change, so correcting one fixes the table in front of the person
1241
+ correcting it. Every other column travels with the row in `extra`, **keyed by
1242
+ its own heading**, which is what makes the placeholder chips honest: the chip
1243
+ inserts the same string the object is keyed by, so a heading called `Client Ref`
1244
+ and the token `{Client Ref}` cannot disagree. The other way in is a textarea of
1245
+ `Name, 04xx xxx xxx` lines — split on the tab where there is one, otherwise on
1246
+ the LAST comma, because the CRM writes people surname-first and splitting on the
1247
+ first would make "Darshini, 0412 345 678" the phone number.
1248
+
1249
+ Two details in the parsing are worth knowing because both fail silently
1250
+ otherwise. A cell arriving as a JS **number** of nine digits beginning with 4 is
1251
+ an Australian mobile that Excel stored as `412345678`, and the leading zero goes
1252
+ back on. And a sheet whose first row is a person rather than a heading keeps
1253
+ that person — the test for it is narrow (nothing in row one reads as a heading
1254
+ we know AND one of its cells is a phone number) because both ways of guessing
1255
+ wrong are visible on the preview table immediately.
1256
+
1257
+ **Step two counts the footer.** `segmentCount` is run over `body + "\n" +
1258
+ footer` whenever `footer_required`, because that is the message the recipient's
1259
+ phone is charged for — a body that fits one part beside a footer that does not
1260
+ is a two-part message, and a counter reading "1 SMS" would be wrong on every
1261
+ send. The body limit shown is `max_body_length − footer.length − 1` for the same
1262
+ reason. The preview panel on the right is fetched from `campaigns/preview`,
1263
+ debounced 400ms: **the server renders it**, so what is on screen is the same
1264
+ code that fills `{first_name}` when the campaign runs rather than a second
1265
+ implementation that agrees until it does not.
1266
+
1267
+ **Step three draws the report before anything is sent.** Create answers with
1268
+ `{accepted, invalid, duplicates, opted_out}` and all four are shown — the
1269
+ invalid rows in a table with the reason per row — and only then is there a
1270
+ **Start sending** button, beside Save as draft. A 422 is printed at the top of
1271
+ the step, never as a toast alone: the refusal is about a list the person is
1272
+ standing in front of, and a notice that fades is no use for reading one.
1273
+
1274
+ **Nothing is optimistic anywhere.** Every action posts and the campaign is then
1275
+ re-read. A Pause that greyed itself out and lost a race with the sender would
1276
+ leave the screen saying "paused" while messages went on going out, which is the
1277
+ one lie this page must never tell.
1278
+
1279
+ `sending` is drawn in the brand tint rather than amber (`campaignPillLive`). A
1280
+ campaign doing exactly what it was told to do is not a warning, and a page of
1281
+ amber chips teaches people to stop reading them — which would cost the state
1282
+ that IS one: `paused`, where nothing is going out until somebody acts.
1283
+
1284
+ #### The opt-out register
1285
+
1286
+ A `StandardModal` off the campaign list — search, the table (number, source,
1287
+ note, who, when), an Add row, and Remove behind the library's confirm dialog.
1288
+ A campaign skips every number on it and says so in the report.
1289
+
1290
+ One-to-one replies are deliberately NOT affected, and `SmsThreadPanel` says so:
1291
+ with `thread.opted_out` true it draws one muted line above the composer —
1292
+ *"This number has opted out of messages. A reply to their message is still
1293
+ allowed."* — and **disables nothing**. The person texted us; refusing to let a
1294
+ member of staff answer because a bulk send would skip them is the wrong answer
1295
+ to the wrong question.
1296
+
1297
+ #### `SmsInbox` gains `campaignsUrl`
1298
+
1299
+ `null` by default, and absent means no link: a deployment with no campaigns
1300
+ screen must not grow one. Set it and a quiet **Campaigns** ghost button appears
1301
+ beside Messaging settings — beside the settings link rather than beside New
1302
+ message, because a campaign is somewhere you GO and a new message is something
1303
+ you DO.
1304
+
1305
+ #### Campaign endpoints
1306
+
1307
+ Added to `makeSmsEndpoints`, so a consumer moving the base moves these too.
1308
+
1309
+ ```
1310
+ GET {base}/campaigns -> {campaigns: [Campaign], settings: {per_minute, max_recipients, footer, footer_required, max_body_length}}
1311
+ POST {base}/campaigns {line_id, name, body, recipients: [{name?, number, extra?}]}
1312
+ -> 201 {campaign, report: {accepted, invalid: [{row, name, number, reason}], duplicates, opted_out: [{name, number}]}}
1313
+ POST {base}/campaigns/preview {body, recipients} -> {previews: [{name, number, body, segments}]}
1314
+ GET {base}/campaigns/{id} -> {campaign}
1315
+ GET {base}/campaigns/{id}/recipients?status&page -> {recipients: [Recipient], meta: {total, per_page, current_page, last_page}}
1316
+ POST {base}/campaigns/{id}/start | /pause | /cancel | /retry-failed -> {campaign} (422 {message} on an illegal transition)
1317
+ DELETE {base}/campaigns/{id} (draft only)
1318
+ GET {base}/opt-outs?search -> {opt_outs: [{id, number, display_number, source, note, user, created_at}]}
1319
+ POST {base}/opt-outs {number, note?} -> 201 {opt_out}
1320
+ DELETE {base}/opt-outs/{id}
1321
+ ```
1322
+
1323
+ `Campaign` is `{id, name, status: draft|sending|paused|completed|cancelled,
1324
+ line, body, footer, user, counts: {total, pending, sent, failed, skipped},
1325
+ progress, last_error, started_at, completed_at, created_at,
1326
+ estimated_minutes_remaining}`; `Recipient` is `{id, name, number,
1327
+ display_number, status: pending|sent|failed|skipped, error, retries, sent_at,
1328
+ message_id, thread_id}`.
1329
+
1330
+ Lines come from the module's own `GET {base}/lines`. **Both `{lines}` and
1331
+ `{data}` are read** — the first is the campaign contract's spelling, the second
1332
+ is what that route has always answered for the inbox — so the page works
1333
+ against either without a second route.
1334
+
1335
+ Also exported: `DEFAULT_CAMPAIGN_SETTINGS`, `CAMPAIGN_STATUSES`,
1336
+ `RECIPIENT_STATUSES`, and the pure parsers `parsePastedRecipients`,
1337
+ `readSheetRows`, `recipientsFromRecords`, `defaultCampaignName` and
1338
+ `estimateMinutes`.
1339
+
857
1340
  #### The fixture
858
1341
 
859
1342
  `yarn dev:sms` serves `dev/sms-fixture.html` on port 5181 against an in-memory
package/package.json CHANGED
@@ -93,7 +93,7 @@
93
93
  "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
94
94
  },
95
95
  "name": "@visns-studio/visns-components",
96
- "version": "6.32.3",
96
+ "version": "6.34.3",
97
97
  "description": "Various packages to assist in the development of our Custom Applications.",
98
98
  "main": "src/index.js",
99
99
  "files": [
@@ -115,6 +115,7 @@ import {
115
115
  isPickerRowAction,
116
116
  normalisePickerOptions,
117
117
  pickerOptionsCacheKey,
118
+ resolveRowActionDisabled,
118
119
  splitSettingEntries,
119
120
  } from './utils/rowActionSettings';
120
121
 
@@ -4303,12 +4304,50 @@ const DataGrid = forwardRef(
4303
4304
  return null;
4304
4305
  }
4305
4306
 
4307
+ /*
4308
+ * Every `case` above funnels through here, which is the point:
4309
+ * `disabled` is answered ONCE, for whatever icon the id resolved
4310
+ * to, rather than in twenty-four branches that would drift.
4311
+ *
4312
+ * `disabled` keeps the action in the row and makes it inert;
4313
+ * `condition` (above) still removes it. See
4314
+ * `resolveRowActionDisabled` in utils/rowActionSettings.js for the
4315
+ * contract and for why a throwing predicate is NOT disabled.
4316
+ */
4306
4317
  function getIconComponent(IconComponent) {
4318
+ const { disabled, reason } = resolveRowActionDisabled(s, d);
4319
+
4320
+ /*
4321
+ * A disabled action still SWALLOWS the click. Dropping the
4322
+ * handler entirely would let the event reach the row
4323
+ * underneath, and on most grids that opens the record — so
4324
+ * "greyed out" would still navigate. `handleIconClick`'s own
4325
+ * first two lines are these two, for the same reason.
4326
+ */
4327
+ const swallow = (event) => {
4328
+ event.preventDefault();
4329
+ event.stopPropagation();
4330
+ };
4331
+
4307
4332
  return (
4308
- <span key={`setting-${s.id}`} onClick={handleIconClick}>
4333
+ <span
4334
+ key={`setting-${s.id}`}
4335
+ onClick={disabled ? swallow : handleIconClick}
4336
+ className={
4337
+ disabled ? styles['vs-action--disabled'] : undefined
4338
+ }
4339
+ aria-disabled={disabled ? 'true' : undefined}
4340
+ tabIndex={disabled ? -1 : undefined}
4341
+ >
4309
4342
  <IconComponent
4310
4343
  data-tooltip-id="system-tooltip"
4311
- data-tooltip-content={tooltipContent}
4344
+ /* The reason replaces the action's own tooltip:
4345
+ the cell is one 18px glyph and there is nowhere
4346
+ else for it to go. No reason given — a bare
4347
+ `true` — keeps the normal title. */
4348
+ data-tooltip-content={
4349
+ disabled && reason ? reason : tooltipContent
4350
+ }
4312
4351
  strokeWidth={2}
4313
4352
  size={18}
4314
4353
  className={styles.tdaction}
@@ -7361,6 +7400,7 @@ const DataGrid = forwardRef(
7361
7400
  <button
7362
7401
  type="button"
7363
7402
  className={styles.rowActionPickerClose}
7403
+ title="Close this list without choosing anything"
7364
7404
  aria-label="Close"
7365
7405
  onClick={closeActionPicker}
7366
7406
  >
@@ -7392,6 +7432,7 @@ const DataGrid = forwardRef(
7392
7432
  <button
7393
7433
  type="button"
7394
7434
  className={`${styles.rowActionPickerOption} ${styles.rowActionPickerClear}`}
7435
+ title="Clear this row's current choice and leave it set to nothing"
7395
7436
  onClick={() =>
7396
7437
  handleActionPickerChoice(
7397
7438
  null
@@ -7408,6 +7449,7 @@ const DataGrid = forwardRef(
7408
7449
  className={
7409
7450
  styles.rowActionPickerOption
7410
7451
  }
7452
+ title={`Set this row to ${option.label}`}
7411
7453
  onClick={() =>
7412
7454
  handleActionPickerChoice(
7413
7455
  option