@visns-studio/visns-components 6.21.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.21.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": [
@@ -37,10 +37,10 @@ import 'reactjs-popup/dist/index.css';
37
37
  import parse from 'html-react-parser';
38
38
  import { saveAs } from 'file-saver';
39
39
  import NumberFilter from '@visns-studio/visns-datagrid-enterprise/NumberFilter';
40
- import SelectFilter from '@visns-studio/visns-datagrid-enterprise/SelectFilter';
41
40
  import DateFilter from '@visns-studio/visns-datagrid-enterprise/DateFilter';
42
41
  import ReactDataGrid from '@visns-studio/visns-datagrid-enterprise';
43
42
  import NativeDateFilterEditor from './columns/NativeDateFilterEditor';
43
+ import SelectFilterEditor from './columns/SelectFilterEditor';
44
44
  import { toast } from 'react-toastify';
45
45
  import fetchUtil from '../utils/fetchUtil';
46
46
  import { confirmDialog } from './utils/ConfirmDialog';
@@ -4387,7 +4387,11 @@ const DataGrid = forwardRef(
4387
4387
  filterEditor = NumberFilter;
4388
4388
  break;
4389
4389
  case 'select':
4390
- filterEditor = SelectFilter;
4390
+ // The grid's own SelectFilter, wrapped so
4391
+ // that opening a dropdown which already
4392
+ // holds a value clears it — see
4393
+ // SelectFilterEditor for the semantics.
4394
+ filterEditor = SelectFilterEditor;
4391
4395
 
4392
4396
  // Inline options: `filter.options` as
4393
4397
  // [{ id, label }].
@@ -4424,6 +4428,15 @@ const DataGrid = forwardRef(
4424
4428
  column.filter.searchable === false,
4425
4429
  // Explicitly enable clear icon
4426
4430
  clearIcon: true,
4431
+ // Opening a filled dropdown clears
4432
+ // it (see SelectFilterEditor). A
4433
+ // column that must always carry a
4434
+ // value opts out with
4435
+ // `filter: { clearOnOpen: false }`;
4436
+ // a multi-select, excluded by
4437
+ // default, opts in with `true`.
4438
+ clearOnOpen:
4439
+ column.filter.clearOnOpen,
4427
4440
  };
4428
4441
  } else {
4429
4442
  // Show loading state for URL-based filters without data
@@ -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;
@@ -1,6 +1,7 @@
1
1
  import React, { useEffect, useRef, useState, useCallback } from 'react';
2
2
  import { Link, useLocation, useNavigate } from 'react-router-dom';
3
3
  import {
4
+ AudioLines,
4
5
  BarChart3,
5
6
  Briefcase,
6
7
  Bug,
@@ -59,6 +60,12 @@ const NAV_ICONS = {
59
60
  // key. Module scope because two surfaces read it now: the header's action
60
61
  // chips and — when a project opts in — the sidebar's pinned action rows.
61
62
  const SETTING_ICONS = {
63
+ // A waveform, for recordings. `phone` is a handset and now belongs to the
64
+ // live Zoom Phone roster, which is a different question entirely — one chip
65
+ // is "listen to a call that happened", the other is "who is on a call right
66
+ // now", and two identical handsets in one header made them impossible to
67
+ // tell apart.
68
+ audioLines: AudioLines,
62
69
  bug: Bug,
63
70
  calendar: Calendar,
64
71
  key: KeyRound,
@@ -607,7 +614,9 @@ function Navigation({
607
614
  className={
608
615
  cleanUrl(child.url) ===
609
616
  bestMatchingUrl(
610
- n.children.map((one) => one.url),
617
+ n.children.map(
618
+ (one) => one.url
619
+ ),
611
620
  currentPage
612
621
  )
613
622
  ? styles.active