@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.
- package/README.md +483 -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 +11 -4
- package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
- package/src/components/auth/Profile.jsx +422 -26
- package/src/components/auth/Reset.jsx +1 -1
- package/src/components/auth/TwoFactorAuth.jsx +1 -1
- package/src/components/auth/Verify.jsx +2 -2
- 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/emailCampaigns/BlockEditor.jsx +486 -0
- package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
- package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
- package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
- package/src/components/emailCampaigns/EmailLists.jsx +505 -0
- package/src/components/emailCampaigns/emailCampaignApi.js +135 -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 +2517 -0
- package/src/components/sms/SmsInbox.jsx +20 -5
- package/src/components/sms/SmsLineSettings.jsx +2 -5
- 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/EmailCampaigns.module.scss +922 -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 +484 -4
- package/src/components/styles/StandardModal.module.scss +273 -0
- package/src/components/styles/Vault.module.scss +5 -3
- package/src/components/styles/global.css +38 -7
- 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 +107 -1
- 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.
|
|
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
|
|
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
|
-
|
|
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
|