@visns-studio/visns-components 6.23.0 → 6.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json 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.23.0",
96
+ "version": "6.23.1",
97
97
  "description": "Various packages to assist in the development of our Custom Applications.",
98
98
  "main": "src/index.js",
99
99
  "files": [
@@ -0,0 +1,299 @@
1
+ import React, { useMemo, useState } from 'react';
2
+
3
+ import styles from './styles/JsonView.module.scss';
4
+
5
+ /**
6
+ * Where a string stops being a value and starts being a document.
7
+ *
8
+ * Exported because the truncation is observable — a caller that wants to know
9
+ * whether a field will be cut before it renders should ask the same number the
10
+ * component uses rather than restate it.
11
+ */
12
+ export const STRING_PREVIEW_LIMIT = 200;
13
+
14
+ /**
15
+ * Parse text that MIGHT be JSON, and answer only for the shapes worth drawing.
16
+ *
17
+ * `JSON.parse` is happy with `42`, `"a note"` and `true` — all three are valid
18
+ * JSON documents by the letter of the spec, and all three are also exactly what
19
+ * an ordinary hand-typed note looks like. Rendering "a note" as a one-row tree
20
+ * with a quoted string in it is worse than rendering the note, so a bare
21
+ * primitive is deliberately NOT JSON for this purpose: only an object or an
22
+ * array earns the tree.
23
+ *
24
+ * Returns the parsed value, or `null` for anything else — including the empty
25
+ * string, which parses as nothing at all.
26
+ *
27
+ * @param {string} text Raw text, leading and trailing space included.
28
+ * @returns {object|Array|null}
29
+ */
30
+ export const parseJsonDocument = (text) => {
31
+ const trimmed = String(text ?? '').trim();
32
+
33
+ // Cheap gate before the parse. A 32KB note that is plainly prose should
34
+ // not cost a parse on every keystroke of the field above it.
35
+ if (trimmed === '' || !/^[{[]/.test(trimmed)) return null;
36
+
37
+ try {
38
+ const parsed = JSON.parse(trimmed);
39
+
40
+ return parsed !== null && typeof parsed === 'object' ? parsed : null;
41
+ } catch (error) {
42
+ // Not JSON, which is an answer rather than a failure.
43
+ return null;
44
+ }
45
+ };
46
+
47
+ /** An object or an array — the two things that get a collapse toggle. */
48
+ const isContainer = (value) => value !== null && typeof value === 'object';
49
+
50
+ const childEntries = (value) =>
51
+ Array.isArray(value)
52
+ ? value.map((item, index) => [index, item])
53
+ : Object.entries(value);
54
+
55
+ /**
56
+ * What a collapsed node says about itself.
57
+ *
58
+ * The count is the whole point: `{…}` alone tells the reader nothing about
59
+ * whether opening it is worth the scroll, and `[…] 129 items` tells them
60
+ * everything.
61
+ */
62
+ const summarise = (value) => {
63
+ const count = Array.isArray(value) ? value.length : Object.keys(value).length;
64
+ const noun = Array.isArray(value) ? 'item' : 'key';
65
+
66
+ return `${Array.isArray(value) ? '[…]' : '{…}'} ${count} ${noun}${
67
+ count === 1 ? '' : 's'
68
+ }`;
69
+ };
70
+
71
+ /** `{}` / `[]`, for a container with nothing in it to expand. */
72
+ const emptyLiteral = (value) => (Array.isArray(value) ? '[]' : '{}');
73
+
74
+ /**
75
+ * A string value, which is the only primitive that can be too big to print.
76
+ *
77
+ * TWO reasons a string gets a toggle, and they are the same reason:
78
+ *
79
+ * It is long. 200 characters is about a line and a half here; past that the
80
+ * value stops being something you read in passing and starts pushing the rest
81
+ * of the document off the screen.
82
+ *
83
+ * It has newlines in it. This is the case that matters for the vault: a
84
+ * credentials backup keeps the actual credential in a `content` field with
85
+ * embedded line breaks, so squashing it onto one line destroys the only
86
+ * structure it has. A multi-line string is therefore treated as long even
87
+ * when it is short, and the expanded form is `pre-wrap`.
88
+ *
89
+ * The collapsed form is deliberately one line — first line, cut to the limit —
90
+ * because a preview that reflows is not a preview.
91
+ */
92
+ const JsonString = ({ value }) => {
93
+ const [open, setOpen] = useState(false);
94
+
95
+ const multiline = value.includes('\n');
96
+ const long = value.length > STRING_PREVIEW_LIMIT;
97
+
98
+ if (!multiline && !long) {
99
+ return <span className={styles.string}>"{value}"</span>;
100
+ }
101
+
102
+ if (open) {
103
+ return (
104
+ <span className={styles.valueCell}>
105
+ <span className={`${styles.string} ${styles.stringOpen}`}>
106
+ "{value}"
107
+ </span>
108
+ <button
109
+ type="button"
110
+ className={styles.more}
111
+ onClick={() => setOpen(false)}
112
+ >
113
+ show less
114
+ </button>
115
+ </span>
116
+ );
117
+ }
118
+
119
+ const firstLine = value.split('\n', 1)[0];
120
+ const preview =
121
+ firstLine.length > STRING_PREVIEW_LIMIT
122
+ ? firstLine.slice(0, STRING_PREVIEW_LIMIT)
123
+ : firstLine;
124
+
125
+ return (
126
+ <span className={styles.valueCell}>
127
+ <span className={styles.string}>"{preview}…"</span>
128
+ <button
129
+ type="button"
130
+ className={styles.more}
131
+ onClick={() => setOpen(true)}
132
+ title={
133
+ multiline
134
+ ? `${value.split('\n').length} lines, ${value.length} characters`
135
+ : `${value.length} characters`
136
+ }
137
+ >
138
+ show all
139
+ </button>
140
+ </span>
141
+ );
142
+ };
143
+
144
+ /** Everything that is not a container: tinted by type, so the type is readable. */
145
+ const JsonPrimitive = ({ value }) => {
146
+ if (value === null) return <span className={styles.null}>null</span>;
147
+
148
+ switch (typeof value) {
149
+ case 'string':
150
+ return <JsonString value={value} />;
151
+ case 'number':
152
+ // `String()` rather than the raw number so NaN and Infinity print
153
+ // as themselves: neither can come out of JSON.parse, but this
154
+ // component takes an already-parsed value from anywhere.
155
+ return <span className={styles.number}>{String(value)}</span>;
156
+ case 'boolean':
157
+ return <span className={styles.boolean}>{value ? 'true' : 'false'}</span>;
158
+ case 'undefined':
159
+ return <span className={styles.null}>undefined</span>;
160
+ default:
161
+ // A function or a symbol, which only a hand-built value can carry.
162
+ return <span className={styles.null}>{String(value)}</span>;
163
+ }
164
+ };
165
+
166
+ /**
167
+ * One row: a key, and either a value or a subtree behind a toggle.
168
+ *
169
+ * The toggle is a real `<button>` wrapping the caret, the key AND the summary,
170
+ * so the hit target is the whole line and there is one tab stop per node rather
171
+ * than one per glyph. There is no `role="tree"` here on purpose: that role
172
+ * promises arrow-key navigation, and a tree that does not answer arrow keys is
173
+ * worse for a screen reader than the plain nested list this actually is.
174
+ */
175
+ const JsonNode = ({ label, isIndex, value, depth, collapsedDepth }) => {
176
+ const container = isContainer(value);
177
+ const entries = useMemo(
178
+ () => (container ? childEntries(value) : []),
179
+ [container, value]
180
+ );
181
+
182
+ // The root is depth 0, so `collapsedDepth: 2` opens the root and its
183
+ // children and collapses everything from the grandchildren down. On the
184
+ // vault's backup payload that is exactly right: the document and its
185
+ // `credentials` array are open, and the 129 credentials inside it are 129
186
+ // one-line summaries rather than 129 open records.
187
+ const [open, setOpen] = useState(depth < collapsedDepth);
188
+
189
+ const key = label === null ? null : (
190
+ <span className={isIndex ? styles.index : styles.key}>
191
+ {String(label)}
192
+ <span className={styles.colon}>:</span>
193
+ </span>
194
+ );
195
+
196
+ if (!container || entries.length === 0) {
197
+ return (
198
+ <li className={styles.row}>
199
+ <div className={styles.line}>
200
+ <span className={styles.spacer} aria-hidden="true" />
201
+ {key}
202
+ {container ? (
203
+ <span className={styles.summary}>{emptyLiteral(value)}</span>
204
+ ) : (
205
+ <JsonPrimitive value={value} />
206
+ )}
207
+ </div>
208
+ </li>
209
+ );
210
+ }
211
+
212
+ return (
213
+ <li className={styles.row}>
214
+ <div className={styles.line}>
215
+ <button
216
+ type="button"
217
+ className={styles.toggle}
218
+ aria-expanded={open}
219
+ onClick={() => setOpen((v) => !v)}
220
+ >
221
+ <span className={styles.caret} aria-hidden="true">
222
+ {open ? '▾' : '▸'}
223
+ </span>
224
+ {key}
225
+ <span className={styles.summary}>
226
+ {open ? (Array.isArray(value) ? '[' : '{') : summarise(value)}
227
+ </span>
228
+ </button>
229
+ </div>
230
+
231
+ {open && (
232
+ <>
233
+ <ul className={`${styles.list} ${styles.children}`}>
234
+ {entries.map(([childLabel, childValue]) => (
235
+ <JsonNode
236
+ key={String(childLabel)}
237
+ label={childLabel}
238
+ isIndex={Array.isArray(value)}
239
+ value={childValue}
240
+ depth={depth + 1}
241
+ collapsedDepth={collapsedDepth}
242
+ />
243
+ ))}
244
+ </ul>
245
+ {/* The closing bracket, so an open node reads as a pair
246
+ rather than as a bracket somebody forgot to shut. */}
247
+ <div className={styles.line}>
248
+ <span className={styles.spacer} aria-hidden="true" />
249
+ <span className={styles.summary}>
250
+ {Array.isArray(value) ? ']' : '}'}
251
+ </span>
252
+ </div>
253
+ </>
254
+ )}
255
+ </li>
256
+ );
257
+ };
258
+
259
+ /**
260
+ * A parsed JSON value, as something a person can actually read.
261
+ *
262
+ * Written for the vault's notes, where `zoho:credentials-backup` leaves a
263
+ * 32KB machine-written blob — `{source, account_id, …, credentials: […]}` — in
264
+ * a field whose only previous rendering was a three-row textarea. Nothing in
265
+ * here knows that, and nothing should: it takes a value and draws it.
266
+ *
267
+ * READ-ONLY, and that is the design rather than an omission. Editing JSON as a
268
+ * tree means deciding what happens when a key is renamed to one that already
269
+ * exists, what a number that is being typed means halfway through, and what to
270
+ * do with a value the user has made syntactically impossible. The consumer
271
+ * keeps the raw text as the single source of truth and toggles between the two;
272
+ * see VaultEntryForm, where the notes VALUE is always the raw string and this
273
+ * view is presentation only.
274
+ *
275
+ * The value is passed ALREADY PARSED. A component that took text would have to
276
+ * decide what a parse failure looks like on screen, and the answer is always
277
+ * "show the text instead", which is a decision belonging to the screen and not
278
+ * to the viewer. `parseJsonDocument` above is the shared way to make that call.
279
+ *
280
+ * @param {*} value Any parsed JS value; objects and arrays get a tree.
281
+ * @param {number} collapsedDepth Nodes at this depth or deeper start collapsed.
282
+ * The root is depth 0. Default 2.
283
+ * @param {string} className Added to the root, for sizing by the caller.
284
+ */
285
+ const JsonView = ({ value, collapsedDepth = 2, className, ...rest }) => (
286
+ <div className={[styles.root, className].filter(Boolean).join(' ')} {...rest}>
287
+ <ul className={styles.list}>
288
+ <JsonNode
289
+ label={null}
290
+ isIndex={false}
291
+ value={value}
292
+ depth={0}
293
+ collapsedDepth={collapsedDepth}
294
+ />
295
+ </ul>
296
+ </div>
297
+ );
298
+
299
+ export default JsonView;
@@ -0,0 +1,233 @@
1
+ @use 'surface' as *;
2
+
3
+ /**
4
+ * JsonView — a parsed JSON value drawn as a collapsible tree.
5
+ *
6
+ * The palette is the page-primitive one (`_surface.scss`): two levels, so a
7
+ * project that defines nothing gets something legible and a page that already
8
+ * has a palette sets `--vs-*` once on its own root. Nothing here carries a
9
+ * colour of its own except the four type tints, and even those are mixed
10
+ * against the app's body ink rather than declared outright — see below.
11
+ *
12
+ * Sizing is the CALLER's job. The root has no height, no scroll and no border:
13
+ * a tree inside a modal wants a capped scroll region, the same tree on a page
14
+ * wants to be as tall as it is, and a component that decides that itself is one
15
+ * the next screen has to fight.
16
+ */
17
+
18
+ $jv-mono: 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
19
+
20
+ .root {
21
+ @include surface-tokens;
22
+
23
+ /**
24
+ * The type tints, and why each is a MIX rather than a hex value.
25
+ *
26
+ * A JSON viewer needs four inks that are distinguishable from each other
27
+ * and readable on the page behind them — and this library does not know
28
+ * which page that is. A fixed `#1f8b4c` is a fine green on white and an
29
+ * unreadable one on a dark surface.
30
+ *
31
+ * Mixing the hue with the app's own body ink solves both directions at
32
+ * once: on a light theme the ink is near-black, so the hue darkens and
33
+ * gains contrast against white; on a dark theme the ink is near-white, so
34
+ * the SAME declaration lightens it against the dark ground. The hue stays
35
+ * recognisable either way because it is three quarters of the mix.
36
+ */
37
+ --jv-key: var(--vs-json-key, color-mix(in srgb, var(--pr-heading) 80%, var(--pr-ink)));
38
+ --jv-string: var(--vs-json-string, color-mix(in srgb, #1f8b4c 74%, var(--pr-ink)));
39
+ --jv-number: var(--vs-json-number, color-mix(in srgb, #2f6fd0 74%, var(--pr-ink)));
40
+ --jv-boolean: var(
41
+ --vs-json-boolean,
42
+ color-mix(in srgb, #8a5cd0 74%, var(--pr-ink))
43
+ );
44
+ // The indent guide is a hairline that must read as structure and not as a
45
+ // border; the page's own line colour is too heavy repeated every level.
46
+ --jv-guide: var(--vs-json-guide, color-mix(in srgb, var(--pr-ink) 16%, transparent));
47
+
48
+ color: var(--pr-ink);
49
+ font-family: $jv-mono;
50
+ // Small and tight: the value of this view over a textarea is how much of
51
+ // the document fits on one screen.
52
+ font-size: 0.76rem;
53
+ line-height: 1.45;
54
+ text-align: left;
55
+ // A long unbroken token — a URL, a base64 blob — must wrap rather than
56
+ // push a horizontal scrollbar under the whole tree.
57
+ overflow-wrap: anywhere;
58
+ }
59
+
60
+ /* The size is declared on the elements as well as the root: inherited
61
+ font-size loses to ANY rule that names the element directly, however
62
+ specific the ancestor — and this tree mounts inside host apps whose global
63
+ stylesheets do exactly that to li, span and button. Restating `inherit`
64
+ here puts a declaration on every element, outranking a host's bare
65
+ element rules (0-0-1 vs this 0-1-0). `:where()` keeps the inner selector
66
+ at zero so the file's own later, smaller sizes (`.more`) still win. */
67
+ .root :where(ul, li, span, button) {
68
+ font-family: inherit;
69
+ font-size: inherit;
70
+ line-height: inherit;
71
+ }
72
+
73
+ .list {
74
+ padding: 0;
75
+ margin: 0;
76
+ list-style: none;
77
+ }
78
+
79
+ /* Each level is indented by its own guide line, so the depth is readable
80
+ without counting spaces. */
81
+ .children {
82
+ padding-left: 0.7rem;
83
+ margin-left: 0.28rem;
84
+ border-left: 1px solid var(--jv-guide);
85
+ }
86
+
87
+ .row {
88
+ // The host CRM styles bare list items in places; nothing here inherits
89
+ // anything it needs. Same reasoning as Vault.module.scss's header.
90
+ padding: 0;
91
+ margin: 0;
92
+ list-style: none;
93
+ }
94
+
95
+ .line {
96
+ display: flex;
97
+ gap: 0.3rem;
98
+ align-items: flex-start;
99
+ min-width: 0;
100
+ }
101
+
102
+ /* Sits under the caret of a node that has one, so keys line up whether or not
103
+ the row is expandable. */
104
+ .spacer {
105
+ flex: 0 0 0.85rem;
106
+ width: 0.85rem;
107
+ }
108
+
109
+ .toggle {
110
+ display: inline-flex;
111
+ gap: 0.3rem;
112
+ align-items: flex-start;
113
+ box-sizing: border-box;
114
+ width: auto;
115
+ min-width: 0;
116
+ height: auto;
117
+ min-height: 0;
118
+ padding: 0 0.15rem 0 0;
119
+ margin: 0;
120
+ color: inherit;
121
+ font: inherit;
122
+ text-align: left;
123
+ text-transform: none;
124
+ letter-spacing: normal;
125
+ background: none;
126
+ border: 0;
127
+ border-radius: var(--pr-radius, 6px);
128
+ box-shadow: none;
129
+ cursor: pointer;
130
+ appearance: none;
131
+
132
+ &:hover {
133
+ background: var(--pr-quiet);
134
+ }
135
+
136
+ &:focus-visible {
137
+ outline: 2px solid var(--pr-accent);
138
+ outline-offset: 1px;
139
+ }
140
+ }
141
+
142
+ .caret {
143
+ flex: 0 0 0.85rem;
144
+ width: 0.85rem;
145
+ color: var(--pr-muted);
146
+ /* The two glyphs have different optical widths; a fixed box stops the key
147
+ beside them shifting by a pixel on every open and close. */
148
+ text-align: left;
149
+ }
150
+
151
+ .key {
152
+ color: var(--jv-key);
153
+ font-weight: 600;
154
+ }
155
+
156
+ /* An array index is not a name, and painting it like one makes a long list look
157
+ like a hundred different keys. */
158
+ .index {
159
+ color: var(--pr-muted);
160
+ }
161
+
162
+ .colon {
163
+ color: var(--pr-muted);
164
+ font-weight: 400;
165
+ }
166
+
167
+ /* `{…} 5 keys`, and the brackets of an open node. */
168
+ .summary {
169
+ color: var(--pr-muted);
170
+ }
171
+
172
+ .valueCell {
173
+ display: inline-flex;
174
+ flex-wrap: wrap;
175
+ gap: 0.35rem;
176
+ align-items: baseline;
177
+ min-width: 0;
178
+ }
179
+
180
+ .string {
181
+ color: var(--jv-string);
182
+ }
183
+
184
+ /* The expanded form of a multi-line string. This is where the vault's
185
+ credentials actually live — a `content` field with embedded newlines — so the
186
+ line breaks are the content, not whitespace to be collapsed. */
187
+ .stringOpen {
188
+ display: block;
189
+ white-space: pre-wrap;
190
+ }
191
+
192
+ .number {
193
+ color: var(--jv-number);
194
+ font-variant-numeric: tabular-nums;
195
+ }
196
+
197
+ .boolean {
198
+ color: var(--jv-boolean);
199
+ font-weight: 600;
200
+ }
201
+
202
+ .null {
203
+ color: var(--pr-muted);
204
+ font-style: italic;
205
+ }
206
+
207
+ /* "show all" / "show less" on a truncated value. A link rather than a chip: it
208
+ sits inside a line of text and must not become the loudest thing on it. */
209
+ .more {
210
+ box-sizing: border-box;
211
+ width: auto;
212
+ height: auto;
213
+ min-width: 0;
214
+ min-height: 0;
215
+ padding: 0;
216
+ margin: 0;
217
+ color: var(--pr-accent);
218
+ font: inherit;
219
+ font-size: 0.72rem;
220
+ text-decoration: underline;
221
+ text-transform: none;
222
+ letter-spacing: normal;
223
+ background: none;
224
+ border: 0;
225
+ box-shadow: none;
226
+ cursor: pointer;
227
+ appearance: none;
228
+
229
+ &:focus-visible {
230
+ outline: 2px solid var(--pr-accent);
231
+ outline-offset: 2px;
232
+ }
233
+ }
@@ -1284,6 +1284,74 @@ input.checkControl {
1284
1284
  resize: vertical;
1285
1285
  }
1286
1286
 
1287
+ // ---------------------------------------------------------------------------
1288
+ // Notes: the Formatted / Raw toggle
1289
+ // ---------------------------------------------------------------------------
1290
+ // Notes holding a machine-written JSON backup are READ as a tree (JsonView) and
1291
+ // EDITED as text. Two chips rather than a switch, because a switch has to be
1292
+ // labelled with the state it is not in and there is no good way to word that
1293
+ // here; two chips name both and light the one you are looking at.
1294
+
1295
+ .viewToggle {
1296
+ display: inline-flex;
1297
+ gap: 0.25rem;
1298
+ align-self: flex-start;
1299
+ padding: 0.15rem;
1300
+ border: 1px solid var(--v-line);
1301
+ border-radius: var(--v-btn-br);
1302
+ background: var(--v-surface);
1303
+ }
1304
+
1305
+ .viewChip {
1306
+ @include vault-reset;
1307
+ @include vault-focus;
1308
+
1309
+ display: inline-flex;
1310
+ align-items: center;
1311
+ padding: 0.2rem 0.55rem;
1312
+ border: 1px solid transparent;
1313
+ border-radius: calc(var(--v-btn-br) - 1px);
1314
+ background: none;
1315
+ color: var(--v-muted);
1316
+ font-size: 0.72rem;
1317
+ font-weight: 600;
1318
+ letter-spacing: 0.02em;
1319
+ cursor: pointer;
1320
+
1321
+ &:hover:not(:disabled) {
1322
+ color: var(--v-primary);
1323
+ }
1324
+
1325
+ // Disabled here means "the JSON is broken, so there is nothing to lay
1326
+ // out"; the chip carries a title saying so. Dimmed rather than removed —
1327
+ // a control that disappears takes its own explanation with it.
1328
+ &:disabled {
1329
+ cursor: default;
1330
+ opacity: 0.45;
1331
+ }
1332
+ }
1333
+
1334
+ .viewChipOn {
1335
+ border-color: var(--v-line);
1336
+ background: var(--v-background);
1337
+ color: var(--v-primary);
1338
+ }
1339
+
1340
+ // The tree's container. JsonView declares no height of its own on purpose, so
1341
+ // the cap belongs here: 50vh keeps a 32KB backup scrolling inside the field
1342
+ // instead of pushing Save off the bottom of the dialog.
1343
+ .notesJson {
1344
+ @include vault-focus;
1345
+
1346
+ box-sizing: border-box;
1347
+ max-height: 50vh;
1348
+ padding: 0.5rem 0.6rem;
1349
+ overflow: auto;
1350
+ border: 1px solid var(--v-line);
1351
+ border-radius: var(--v-btn-br);
1352
+ background: var(--v-surface);
1353
+ }
1354
+
1287
1355
  .help {
1288
1356
  margin: 0;
1289
1357
  color: var(--v-muted);
@@ -13,6 +13,7 @@ import {
13
13
  } from 'lucide-react';
14
14
 
15
15
  import CustomFetch from '../Fetch';
16
+ import JsonView, { parseJsonDocument } from '../JsonView';
16
17
  import StandardModal from '../generic/StandardModal';
17
18
  import styles from '../styles/Vault.module.scss';
18
19
  import PasswordGenerator from './PasswordGenerator';
@@ -41,6 +42,26 @@ const EMPTY = {
41
42
  * it. So the form tracks intent rather than value, and only puts a key in the
42
43
  * body when the user actually expressed one.
43
44
  *
45
+ * **Notes are the same rule for a different reason.** They are not secret, but
46
+ * they are encrypted and so are deliberately absent from the LIST row — and the
47
+ * list row is what opens this form. Seeding `notes` from it and then sending
48
+ * `notes: ""` back means every edit made from the list silently wipes whatever
49
+ * the entry held, which is how a 32KB machine-written backup can vanish behind
50
+ * a one-word title change. So an existing entry fetches its own detail payload
51
+ * when the form opens, and the `notes` key goes in the body only when there is
52
+ * an answer to send: always for a new entry, and for an existing one only once
53
+ * that fetch has succeeded. A fetch that failed omits the key entirely, which
54
+ * is the API's "leave it alone" — the rest of the form still saves.
55
+ *
56
+ * Once those notes are on screen they are often not prose at all:
57
+ * `zoho:credentials-backup` leaves a pretty-printed
58
+ * `{source, account_id, …, credentials: [{label, content}…]}` blob in the
59
+ * field, and reading 32KB of it through a three-row textarea is miserable. So
60
+ * notes that parse as a JSON object or array get a Formatted/Raw toggle and
61
+ * default to a collapsible tree (`JsonView`). It changes nothing about what is
62
+ * saved: the notes VALUE is the raw text either way, the tree is read-only, and
63
+ * every edit still happens in the textarea behind the Raw chip.
64
+ *
44
65
  * The 2FA field takes either a bare base32 secret or the whole `otpauth://`
45
66
  * link, and says what it understood — digits, period, algorithm — before the
46
67
  * entry is saved, because the failure mode otherwise is a code that is simply
@@ -104,12 +125,24 @@ const VaultEntryForm = ({
104
125
  const [dropping, setDropping] = useState(false);
105
126
  const qrFileRef = useRef(null);
106
127
 
128
+ // 'ready' (nothing to load — a new entry) | 'loading' | 'loaded' | 'failed'.
129
+ // Only 'ready' and 'loaded' are safe to assert intent from; see submit().
130
+ const [notesState, setNotesState] = useState(() =>
131
+ entry?.id ? 'loading' : 'ready'
132
+ );
133
+
107
134
  const [errors, setErrors] = useState({});
108
135
  const [saving, setSaving] = useState(false);
109
136
 
110
137
  const mountedRef = useRef(true);
111
138
  const titleRef = useRef(null);
112
139
 
140
+ // The entry id whose notes have already been asked for. A ref rather than a
141
+ // dependency because the fetch must happen once per open, not once per
142
+ // render — `routes` is rebuilt whenever the consumer passes a fresh
143
+ // `endpoints` object, and a keystroke must never cost a round trip.
144
+ const notesRequestedFor = useRef(null);
145
+
113
146
  useEffect(() => {
114
147
  mountedRef.current = true;
115
148
 
@@ -122,6 +155,115 @@ const VaultEntryForm = ({
122
155
  if (isOpen) setTimeout(() => titleRef.current?.focus(), 0);
123
156
  }, [isOpen]);
124
157
 
158
+ /* -------------------------------------------------------------- notes */
159
+
160
+ /**
161
+ * Fetch the entry's notes from the detail endpoint.
162
+ *
163
+ * The list row this form is opened from has no `notes` — the column is
164
+ * encrypted and loaded one entry at a time — so the only honest way to fill
165
+ * the field is to ask for the entry itself. The fetch lives here rather
166
+ * than in the list because this is the component that knows it is editing,
167
+ * and because the notes are of no use to a row that only draws a title.
168
+ *
169
+ * A failure is not toasted: it is not a failed action, it is a field that
170
+ * cannot be edited this time round. The submit path reads `notesState` and
171
+ * omits the key, so the stored notes survive the save either way.
172
+ *
173
+ * Note that the detail endpoint writes a `view` to the access log, so
174
+ * opening this form on an existing entry now leaves a row. That is correct
175
+ * rather than incidental — the notes are decrypted and put on screen, which
176
+ * is exactly what a `view` records.
177
+ */
178
+ const loadNotes = useCallback(() => {
179
+ if (!entry?.id) return;
180
+
181
+ setNotesState('loading');
182
+
183
+ CustomFetch(
184
+ routes.show(entry.id),
185
+ 'GET',
186
+ null,
187
+ (result) => {
188
+ if (!mountedRef.current) return;
189
+
190
+ setValues((prev) => ({ ...prev, notes: result?.notes ?? '' }));
191
+ setNotesState('loaded');
192
+ },
193
+ // An errorCallback, empty on purpose: given one, CustomFetch stops
194
+ // toasting, which is the whole point.
195
+ () => {
196
+ if (!mountedRef.current) return;
197
+
198
+ setNotesState('failed');
199
+ }
200
+ ).catch(() => {
201
+ // Already handled above; swallowed so the rejection is not
202
+ // unhandled.
203
+ });
204
+ }, [entry?.id, routes]);
205
+
206
+ useEffect(() => {
207
+ if (!isOpen || !entry?.id) return;
208
+
209
+ // Once per open. Re-running on every render would refetch while the
210
+ // user types, and worse, would overwrite what they had typed.
211
+ if (notesRequestedFor.current === entry.id) return;
212
+
213
+ notesRequestedFor.current = entry.id;
214
+ loadNotes();
215
+ }, [isOpen, entry?.id, loadNotes]);
216
+
217
+ // Disabled while there is nothing trustworthy to edit: blank-and-editable
218
+ // reads as "this entry has no notes", which is the misunderstanding that
219
+ // wiped them in the first place.
220
+ const notesLocked = notesState === 'loading' || notesState === 'failed';
221
+
222
+ /* --------------------------------------------------- notes as a document */
223
+
224
+ // 'formatted' (the tree) | 'raw' (the textarea, and the only place editing
225
+ // happens). Presentation only — see submit(): the notes VALUE is the raw
226
+ // string in `values.notes` whichever of the two is on screen.
227
+ const [notesView, setNotesView] = useState('formatted');
228
+
229
+ /**
230
+ * The notes as a JSON document, or null if they are not one.
231
+ *
232
+ * `zoho:credentials-backup` writes up to 32KB of pretty-printed
233
+ * `{source, account_id, …, credentials: [{label, content}…]}` into this
234
+ * field, and a textarea three rows tall is a genuinely bad way to read it.
235
+ *
236
+ * Gated on `notesState` for the same reason submit() is: until the detail
237
+ * fetch has answered, `values.notes` is not the entry's notes — it is the
238
+ * empty string the form started with. Parsing that would be answering a
239
+ * question about a value we do not have yet. Because the gate reads the
240
+ * state, this re-runs by itself the moment the fetch lands, which is what
241
+ * makes the toggle appear on an entry opened from the list.
242
+ */
243
+ const notesDocument = useMemo(
244
+ () =>
245
+ notesState === 'ready' || notesState === 'loaded'
246
+ ? parseJsonDocument(values.notes)
247
+ : null,
248
+ [notesState, values.notes]
249
+ );
250
+
251
+ // Shown while the notes ARE a document — and also while the user is in the
252
+ // raw editor with something that is still trying to be one. That second
253
+ // case is the whole reason this is not just `Boolean(notesDocument)`: break
254
+ // a brace while editing and the toggle would otherwise vanish out from
255
+ // under the cursor, taking the way back with it. Instead it stays, with
256
+ // Formatted disabled and saying why. It cannot appear on ordinary prose,
257
+ // because reaching the raw editor at all requires the chips, which require
258
+ // a document.
259
+ const notesToggle =
260
+ Boolean(notesDocument) ||
261
+ (notesView === 'raw' && /^\s*[{[]/.test(values.notes || ''));
262
+
263
+ // The tree is only ever drawn for a document that parsed THIS render, so
264
+ // broken JSON falls back to the textarea rather than to a stale tree.
265
+ const notesFormatted = Boolean(notesDocument) && notesView === 'formatted';
266
+
125
267
  const set = (key, value) => {
126
268
  setValues((prev) => ({ ...prev, [key]: value }));
127
269
  setErrors((prev) => (prev[key] ? { ...prev, [key]: undefined } : prev));
@@ -266,7 +408,6 @@ const VaultEntryForm = ({
266
408
  title: values.title.trim(),
267
409
  url: (values.url || '').trim(),
268
410
  username: (values.username || '').trim(),
269
- notes: values.notes || '',
270
411
  tags,
271
412
  visibility: values.visibility,
272
413
  // Always sent, including as null — that is how an entry gets
@@ -275,6 +416,15 @@ const VaultEntryForm = ({
275
416
  };
276
417
 
277
418
  // Key presence is the contract — see the note at the top of the file.
419
+
420
+ // Notes: sent whenever what is in the box is genuinely what the entry
421
+ // holds. For a new entry that is trivially true; for an existing one it
422
+ // is true only after the detail fetch answered. Anything else omits the
423
+ // key, and the API leaves the stored notes exactly where they were.
424
+ if (notesState === 'ready' || notesState === 'loaded') {
425
+ body.notes = values.notes || '';
426
+ }
427
+
278
428
  if (clearPassword) body.password = '';
279
429
  else if (password !== '') body.password = password;
280
430
 
@@ -694,13 +844,94 @@ const VaultEntryForm = ({
694
844
  {field(
695
845
  'notes',
696
846
  'Notes',
697
- <textarea
698
- id="vault-notes"
699
- className={styles.textarea}
700
- rows={3}
701
- value={values.notes || ''}
702
- onChange={(e) => set('notes', e.target.value)}
703
- />
847
+ <>
848
+ {notesToggle && (
849
+ <div
850
+ className={styles.viewToggle}
851
+ role="group"
852
+ aria-label="Notes view"
853
+ >
854
+ <button
855
+ type="button"
856
+ className={`${styles.viewChip} ${
857
+ notesFormatted ? styles.viewChipOn : ''
858
+ }`}
859
+ aria-pressed={notesFormatted}
860
+ disabled={!notesDocument}
861
+ title={
862
+ notesDocument
863
+ ? undefined
864
+ : 'These notes are no longer valid JSON, so there is nothing to lay out — fix the syntax here and this comes back.'
865
+ }
866
+ onClick={() => setNotesView('formatted')}
867
+ >
868
+ Formatted
869
+ </button>
870
+ <button
871
+ type="button"
872
+ className={`${styles.viewChip} ${
873
+ notesFormatted ? '' : styles.viewChipOn
874
+ }`}
875
+ aria-pressed={!notesFormatted}
876
+ onClick={() => setNotesView('raw')}
877
+ >
878
+ Raw
879
+ </button>
880
+ </div>
881
+ )}
882
+
883
+ {notesFormatted ? (
884
+ // Read-only, and capped so a 32KB backup scrolls
885
+ // inside the field instead of pushing Save off the
886
+ // bottom of the dialog.
887
+ <div
888
+ // The label above says "Notes"; keeping the id
889
+ // here means it still points at what it names.
890
+ // Focusable because a scroll region a mouse can
891
+ // reach and a keyboard cannot is only half a
892
+ // control.
893
+ id="vault-notes"
894
+ className={styles.notesJson}
895
+ role="region"
896
+ aria-label="Notes, laid out as JSON"
897
+ tabIndex={0}
898
+ >
899
+ <JsonView value={notesDocument} />
900
+ </div>
901
+ ) : (
902
+ <textarea
903
+ id="vault-notes"
904
+ className={styles.textarea}
905
+ rows={3}
906
+ value={values.notes || ''}
907
+ disabled={notesLocked}
908
+ placeholder={
909
+ notesState === 'loading' ? 'Loading notes…' : ''
910
+ }
911
+ onChange={(e) => set('notes', e.target.value)}
912
+ />
913
+ )}
914
+
915
+ {notesFormatted && (
916
+ <p className={styles.help}>
917
+ These notes are JSON. Switch to Raw to edit them.
918
+ </p>
919
+ )}
920
+
921
+ {notesState === 'failed' && (
922
+ <p className={styles.fieldError} role="alert">
923
+ The notes could not be loaded, so they cannot be
924
+ edited here — saving will leave them as they are.{' '}
925
+ <button
926
+ type="button"
927
+ className={styles.linkButton}
928
+ onClick={loadNotes}
929
+ >
930
+ Try again
931
+ </button>
932
+ </p>
933
+ )}
934
+ </>
704
935
  )}
705
936
 
706
937
  {field(
@@ -168,6 +168,18 @@ const VaultManagerInner = ({
168
168
  */
169
169
  clientId = null,
170
170
  clientLabel = null,
171
+ /**
172
+ * The order the list opens in, before anyone clicks a column header.
173
+ *
174
+ * The library's default stays most-recently-touched first — on a vault
175
+ * that is worked in, the entry someone just rotated is the one they are
176
+ * about to verify. A host whose vault is browsed rather than worked
177
+ * (looked through by name, like a phone book) passes 'title' / 'asc'.
178
+ * Clicking a header still overrides either way; this is only the opening
179
+ * state.
180
+ */
181
+ defaultSort = 'updated_at',
182
+ defaultDirection = 'desc',
171
183
  }) => {
172
184
  const routes = useMemo(() => resolveVaultEndpoints(endpoints), [endpoints]);
173
185
 
@@ -190,8 +202,8 @@ const VaultManagerInner = ({
190
202
  // production is one page in eight.
191
203
  const [clientOptions, setClientOptions] = useState([]);
192
204
  const [includeDeleted, setIncludeDeleted] = useState(false);
193
- const [sort, setSort] = useState('updated_at');
194
- const [direction, setDirection] = useState('desc');
205
+ const [sort, setSort] = useState(defaultSort);
206
+ const [direction, setDirection] = useState(defaultDirection);
195
207
  const [page, setPage] = useState(1);
196
208
  const [perPage, setPerPage] = useState(
197
209
  Number(perPageProp) > 0 ? Number(perPageProp) : PER_PAGE
package/src/index.js CHANGED
@@ -47,6 +47,11 @@ import ImageModal from './components/ImageModal';
47
47
  import DatePickerPortal from './components/utils/DatePickerPortal';
48
48
  import NativeDateFilterEditor from './components/columns/NativeDateFilterEditor';
49
49
  import ImportWizardModal from './components/ImportWizardModal';
50
+ // A parsed JSON value as a collapsible tree, plus the rule for deciding whether
51
+ // some text is one worth drawing. Written for the vault's notes — a machine
52
+ // written backup blob in a textarea is unreadable — but there is nothing
53
+ // vault-specific in either half, so both live out here.
54
+ import JsonView, { parseJsonDocument, STRING_PREVIEW_LIMIT } from './components/JsonView';
50
55
  export * from './components/columns/ColumnRenderers.jsx';
51
56
 
52
57
  /** Auth Specific Components */
@@ -479,6 +484,9 @@ export {
479
484
  ImportWizardModal,
480
485
  isCompleteOtp,
481
486
  isSuccessBody,
487
+ JsonView,
488
+ parseJsonDocument,
489
+ STRING_PREVIEW_LIMIT,
482
490
  Loader,
483
491
  Login,
484
492
  LogoutScreen,