@visns-studio/visns-components 6.17.2 → 6.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +10 -7
  2. package/src/components/AssociationManager.jsx +344 -177
  3. package/src/components/AsyncSelect.jsx +2 -2
  4. package/src/components/Autocomplete.jsx +57 -12
  5. package/src/components/Breadcrumb.jsx +23 -4
  6. package/src/components/ClientAssociationManager.jsx +321 -145
  7. package/src/components/DataGrid.jsx +625 -20
  8. package/src/components/DropZone.jsx +4 -1
  9. package/src/components/Form.jsx +233 -6
  10. package/src/components/MultiSelect.jsx +2 -2
  11. package/src/components/Navigation.jsx +39 -23
  12. package/src/components/TableFilter.jsx +172 -12
  13. package/src/components/UniversalSearch.jsx +390 -0
  14. package/src/components/auth/Login.jsx +164 -8
  15. package/src/components/auth/Profile.jsx +209 -22
  16. package/src/components/auth/authEndpoints.js +4 -0
  17. package/src/components/auth/webauthn.js +194 -0
  18. package/src/components/columns/ColumnRenderers.jsx +6 -1
  19. package/src/components/controls/DataGridSearch.jsx +11 -5
  20. package/src/components/generic/GenericAuth.jsx +6 -0
  21. package/src/components/generic/GenericDashboard.jsx +93 -36
  22. package/src/components/generic/GenericDetail.jsx +146 -61
  23. package/src/components/generic/GenericIndex.jsx +134 -38
  24. package/src/components/generic/GenericSettings.jsx +220 -0
  25. package/src/components/generic/StandardModal.jsx +165 -8
  26. package/src/components/generic/dashboardChartDefaults.js +230 -0
  27. package/src/components/generic/shared/indexLayout.js +38 -0
  28. package/src/components/settings/IntegrationsManager.jsx +474 -0
  29. package/src/components/sms/SmsLineSettings.jsx +16 -12
  30. package/src/components/styles/AssociationManager.module.scss +4 -363
  31. package/src/components/styles/AuditLog.module.scss +6 -6
  32. package/src/components/styles/AuditLogs.module.scss +3 -3
  33. package/src/components/styles/Autocomplete.module.scss +7 -7
  34. package/src/components/styles/BusinessCardOcr.module.scss +6 -1
  35. package/src/components/styles/CallQueueSettings.module.scss +4 -1
  36. package/src/components/styles/CameraPlacement.module.scss +19 -19
  37. package/src/components/styles/CategorizedDropZone.module.scss +6 -1
  38. package/src/components/styles/ClientAssociationManager.module.scss +5 -289
  39. package/src/components/styles/ClientAuth.module.scss +1 -1
  40. package/src/components/styles/DataGrid.module.scss +455 -8
  41. package/src/components/styles/Field.module.scss +32 -24
  42. package/src/components/styles/Form.module.scss +123 -3
  43. package/src/components/styles/GenericDashboard.module.scss +612 -825
  44. package/src/components/styles/GenericDetail.module.scss +406 -61
  45. package/src/components/styles/GenericDynamic.module.scss +25 -25
  46. package/src/components/styles/GenericEditableTable.module.scss +33 -33
  47. package/src/components/styles/GenericFormBuilder.module.scss +119 -119
  48. package/src/components/styles/GenericIndex.module.scss +483 -38
  49. package/src/components/styles/GenericMain.module.scss +6 -3
  50. package/src/components/styles/GenericQuote.module.scss +399 -81
  51. package/src/components/styles/GenericReport.module.scss +8 -8
  52. package/src/components/styles/GenericSettings.module.scss +209 -0
  53. package/src/components/styles/GenericSort.module.scss +9 -9
  54. package/src/components/styles/Integrations.module.scss +321 -0
  55. package/src/components/styles/List.module.scss +4 -4
  56. package/src/components/styles/Login.module.scss +57 -0
  57. package/src/components/styles/MultiSelect.module.scss +4 -4
  58. package/src/components/styles/Navigation.module.scss +12 -12
  59. package/src/components/styles/Notification.module.scss +12 -8
  60. package/src/components/styles/NotificationList.module.scss +5 -5
  61. package/src/components/styles/Profile.module.scss +5 -5
  62. package/src/components/styles/QrCode.module.scss +4 -4
  63. package/src/components/styles/QuickAction.module.scss +20 -20
  64. package/src/components/styles/SortableQuoteItems.module.scss +4 -4
  65. package/src/components/styles/TableFilter.module.scss +128 -17
  66. package/src/components/styles/TicketConversation.module.scss +497 -0
  67. package/src/components/styles/UniversalSearch.module.scss +229 -0
  68. package/src/components/styles/Vault.module.scss +413 -0
  69. package/src/components/styles/_associationManager.scss +467 -0
  70. package/src/components/styles/_authBrandPanel.scss +15 -9
  71. package/src/components/styles/_controls.scss +43 -39
  72. package/src/components/styles/global-datagrid.css +196 -0
  73. package/src/components/styles/global.css +168 -17
  74. package/src/components/tickets/TicketConversation.jsx +455 -0
  75. package/src/components/utils/rowActionSettings.js +117 -0
  76. package/src/components/vault/VaultClientPicker.jsx +183 -0
  77. package/src/components/vault/VaultEntryForm.jsx +23 -0
  78. package/src/components/vault/VaultManager.jsx +382 -34
  79. package/src/components/vault/VaultShareModal.jsx +454 -0
  80. package/src/components/vault/vaultEndpoints.js +16 -2
  81. package/src/components/vault/vaultShares.js +247 -0
  82. package/src/index.js +35 -0
  83. package/src/utils/fetchUtil.js +38 -14
@@ -0,0 +1,455 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ AlertTriangle,
4
+ ArrowDownLeft,
5
+ ArrowUpRight,
6
+ Loader2,
7
+ Lock,
8
+ ImageOff,
9
+ Mail,
10
+ Paperclip,
11
+ Phone,
12
+ Type,
13
+ Globe,
14
+ } from 'lucide-react';
15
+
16
+ import { toast } from 'react-toastify';
17
+ import parse, { attributesToProps } from 'html-react-parser';
18
+
19
+ import CustomFetch from '../Fetch';
20
+ import styles from '../styles/TicketConversation.module.scss';
21
+
22
+ const CHANNEL_ICON = {
23
+ Email: Mail,
24
+ Phone: Phone,
25
+ Web: Globe,
26
+ };
27
+
28
+ /**
29
+ * A support ticket's conversation.
30
+ *
31
+ * The three kinds of entry are visually distinct because confusing them has a
32
+ * cost, and the costs are not symmetrical:
33
+ *
34
+ * in — the customer wrote it. Left, plain surface.
35
+ * out — we wrote it. Left-accented, tinted, so a reply is obvious at a
36
+ * glance when scanning a long thread.
37
+ * note — an internal note. Marked with a lock and its own colour, because
38
+ * an agent must never mistake one for something the customer has
39
+ * seen. That is the single most expensive mistake in a helpdesk.
40
+ *
41
+ * Ordered by when a message was SENT, not when the sync imported it. The
42
+ * server sorts on `posted_at`; this component does not re-sort.
43
+ */
44
+ const TicketConversation = ({
45
+ ticketId,
46
+ base = '/ajax/tickets',
47
+ onLoaded,
48
+ canReply = true,
49
+ }) => {
50
+ const [entries, setEntries] = useState([]);
51
+ const [ticket, setTicket] = useState(null);
52
+ const [loading, setLoading] = useState(true);
53
+ const [failed, setFailed] = useState(false);
54
+ const mountedRef = useRef(true);
55
+
56
+ useEffect(() => () => {
57
+ mountedRef.current = false;
58
+ }, []);
59
+
60
+ const load = useCallback(() => {
61
+ if (!ticketId) return;
62
+
63
+ setLoading(true);
64
+ setFailed(false);
65
+
66
+ CustomFetch(
67
+ `${base}/${ticketId}/conversation`,
68
+ 'GET',
69
+ {},
70
+ (result) => {
71
+ if (!mountedRef.current) return;
72
+
73
+ setEntries(Array.isArray(result?.data) ? result.data : []);
74
+ setTicket(result?.ticket ?? null);
75
+ setLoading(false);
76
+ onLoaded?.(result?.ticket ?? null);
77
+ },
78
+ null,
79
+ { cache: false }
80
+ ).catch(() => {
81
+ if (!mountedRef.current) return;
82
+
83
+ setLoading(false);
84
+ setFailed(true);
85
+ });
86
+ }, [base, ticketId, onLoaded]);
87
+
88
+ useEffect(() => {
89
+ load();
90
+ }, [load]);
91
+
92
+ const counts = useMemo(() => {
93
+ const out = { in: 0, out: 0, note: 0 };
94
+
95
+ entries.forEach((e) => {
96
+ out[e.direction] = (out[e.direction] ?? 0) + 1;
97
+ });
98
+
99
+ return out;
100
+ }, [entries]);
101
+
102
+ if (loading) {
103
+ return (
104
+ <p className={styles.state}>
105
+ <Loader2 size={16} className={styles.spin} /> Loading conversation…
106
+ </p>
107
+ );
108
+ }
109
+
110
+ if (failed) {
111
+ return (
112
+ <p className={styles.state}>
113
+ <AlertTriangle size={16} /> The conversation could not be loaded.
114
+ </p>
115
+ );
116
+ }
117
+
118
+ return (
119
+ <div className={styles.wrap}>
120
+ {ticket && <TicketHeader ticket={ticket} counts={counts} />}
121
+
122
+ {entries.length === 0 ? (
123
+ <p className={styles.state}>
124
+ Nothing has been said on this ticket yet.
125
+ </p>
126
+ ) : (
127
+ <ol className={styles.thread}>
128
+ {entries.map((entry) => (
129
+ <Entry key={entry.id} entry={entry} />
130
+ ))}
131
+ </ol>
132
+ )}
133
+
134
+ {canReply && (
135
+ <Composer
136
+ base={base}
137
+ ticketId={ticketId}
138
+ onPosted={(entry) => setEntries((prev) => [...prev, entry])}
139
+ />
140
+ )}
141
+ </div>
142
+ );
143
+ };
144
+
145
+ /**
146
+ * Reply, or leave an internal note.
147
+ *
148
+ * The two are one control with an explicit switch rather than two buttons,
149
+ * and the switch RESTATES the consequence in words ("the customer will see
150
+ * this" / "only staff can see this") every time. An agent sending an internal
151
+ * note to a customer is the expensive mistake here, and a toggle whose state
152
+ * you have to remember is how it happens.
153
+ */
154
+ const Composer = ({ base, ticketId, onPosted }) => {
155
+ const [body, setBody] = useState('');
156
+ const [internal, setInternal] = useState(false);
157
+ const [sending, setSending] = useState(false);
158
+
159
+ const send = () => {
160
+ const text = body.trim();
161
+
162
+ if (text === '' || sending) return;
163
+
164
+ setSending(true);
165
+
166
+ CustomFetch(
167
+ `${base}/${ticketId}/reply`,
168
+ 'POST',
169
+ { body: text, internal },
170
+ (result) => {
171
+ setSending(false);
172
+ setBody('');
173
+ toast.success(result?.message || 'Sent');
174
+
175
+ if (result?.data) onPosted(result.data);
176
+ },
177
+ null,
178
+ { silentStatuses: [422, 502] }
179
+ ).catch((error) => {
180
+ setSending(false);
181
+ // 422 and 502 carry the reason — no email to reply to, or
182
+ // Microsoft refused the send — and the reason is the whole point.
183
+ toast.error(error?.data?.message || 'Could not send.');
184
+ });
185
+ };
186
+
187
+ return (
188
+ <div className={`${styles.composer} ${internal ? styles.composerNote : ''}`}>
189
+ <div className={styles.composerBar}>
190
+ <label className={styles.switch}>
191
+ <input
192
+ type="checkbox"
193
+ checked={internal}
194
+ onChange={(e) => setInternal(e.target.checked)}
195
+ />
196
+ Internal note
197
+ </label>
198
+
199
+ <span className={styles.consequence}>
200
+ {internal
201
+ ? 'Only staff can see this.'
202
+ : 'This is emailed to the customer.'}
203
+ </span>
204
+ </div>
205
+
206
+ <textarea
207
+ className={styles.composerInput}
208
+ value={body}
209
+ rows={4}
210
+ placeholder={
211
+ internal
212
+ ? 'Leave a note for the team…'
213
+ : 'Write a reply to the customer…'
214
+ }
215
+ onChange={(e) => setBody(e.target.value)}
216
+ />
217
+
218
+ <div className={styles.composerActions}>
219
+ <button
220
+ type="button"
221
+ className={styles.send}
222
+ onClick={send}
223
+ disabled={sending || body.trim() === ''}
224
+ >
225
+ {sending ? 'Sending…' : internal ? 'Add note' : 'Send reply'}
226
+ </button>
227
+ </div>
228
+ </div>
229
+ );
230
+ };
231
+
232
+ const TicketHeader = ({ ticket, counts }) => {
233
+ const Channel = CHANNEL_ICON[ticket.channel] ?? Mail;
234
+ const closed = (ticket.status_type || '').toLowerCase() === 'closed';
235
+
236
+ return (
237
+ <header className={styles.head}>
238
+ <div className={styles.headMain}>
239
+ <p className={styles.reference}>
240
+ {ticket.reference ? `#${ticket.reference}` : `Ticket ${ticket.id}`}
241
+ <span
242
+ className={`${styles.status} ${closed ? styles.closed : styles.open}`}
243
+ >
244
+ {ticket.status_type || 'Unknown'}
245
+ </span>
246
+ </p>
247
+ <h3>{ticket.subject}</h3>
248
+ </div>
249
+
250
+ <dl className={styles.meta}>
251
+ {ticket.customer && (
252
+ <div>
253
+ <dt>Client</dt>
254
+ <dd>{ticket.customer}</dd>
255
+ </div>
256
+ )}
257
+ {ticket.contact && (
258
+ <div>
259
+ <dt>Raised by</dt>
260
+ <dd>{ticket.contact}</dd>
261
+ </div>
262
+ )}
263
+ {ticket.assignee_name && (
264
+ <div>
265
+ <dt>Assigned to</dt>
266
+ <dd>{ticket.assignee_name}</dd>
267
+ </div>
268
+ )}
269
+ <div>
270
+ <dt>Channel</dt>
271
+ <dd className={styles.channel}>
272
+ <Channel size={13} strokeWidth={2} />
273
+ {ticket.channel || '—'}
274
+ </dd>
275
+ </div>
276
+ <div>
277
+ <dt>Messages</dt>
278
+ <dd>
279
+ {counts.in} in · {counts.out} out
280
+ {counts.note > 0 && ` · ${counts.note} internal`}
281
+ </dd>
282
+ </div>
283
+ </dl>
284
+
285
+ </header>
286
+ );
287
+ };
288
+
289
+ /**
290
+ * One message's body.
291
+ *
292
+ * `body_html` is the server's SANITISED copy of the email, and the only reason
293
+ * it can be rendered at all: the markup came from a stranger, and it has been
294
+ * through an allowlist on the way in (App\Services\Mail\HtmlEmailSanitizer).
295
+ * This component adds no sanitising of its own and must not be given markup
296
+ * from anywhere else — html-react-parser builds elements, it does not clean
297
+ * them.
298
+ *
299
+ * `body` (plain text) is the fallback and is what every note imported before
300
+ * the column existed still shows. Both are always present in the payload, so
301
+ * the plain-text switch below is free.
302
+ *
303
+ * Remote images arrive with their URL parked in `data-blocked-src` and no
304
+ * `src`, so the browser has already declined to fetch them by the time this
305
+ * runs. Swapping them in is a per-message, per-session choice held in state and
306
+ * never persisted — the point of blocking a tracking pixel is that opening the
307
+ * ticket does not announce itself, and a remembered preference would announce
308
+ * it on every message thereafter.
309
+ */
310
+ export const EntryBody = ({ entry }) => {
311
+ const html = entry.body_html || null;
312
+ const [plain, setPlain] = useState(false);
313
+ const [showImages, setShowImages] = useState(false);
314
+
315
+ const blockedCount = useMemo(() => {
316
+ if (!html) return 0;
317
+
318
+ return (html.match(/data-blocked-src=/g) || []).length;
319
+ }, [html]);
320
+
321
+ const options = useMemo(
322
+ () => ({
323
+ replace: (node) => {
324
+ if (node.type !== 'tag' || node.name !== 'img') return undefined;
325
+
326
+ const blocked = node.attribs?.['data-blocked-src'];
327
+
328
+ if (!blocked) return undefined;
329
+
330
+ const props = attributesToProps(node.attribs);
331
+
332
+ delete props['data-blocked-src'];
333
+
334
+ if (showImages) {
335
+ return <img {...props} src={blocked} />;
336
+ }
337
+
338
+ // A placeholder rather than nothing: a signature whose logo has
339
+ // silently vanished reads as a broken message, and the reader
340
+ // has no way to know there is a button that would fix it.
341
+ return (
342
+ <span className={styles.blockedImage} title={blocked}>
343
+ {props.alt || 'Image'}
344
+ </span>
345
+ );
346
+ },
347
+ }),
348
+ [showImages]
349
+ );
350
+
351
+ if (!html) {
352
+ return <p className={styles.body}>{entry.body}</p>;
353
+ }
354
+
355
+ return (
356
+ <>
357
+ <div className={styles.bodyBar}>
358
+ {blockedCount > 0 && !showImages && (
359
+ <button
360
+ type="button"
361
+ className={styles.bodyAction}
362
+ onClick={() => setShowImages(true)}
363
+ >
364
+ <ImageOff size={12} strokeWidth={2} />
365
+ Show {blockedCount === 1 ? 'image' : `${blockedCount} images`}
366
+ </button>
367
+ )}
368
+
369
+ {blockedCount > 0 && !showImages && (
370
+ <span className={styles.bodyHint}>
371
+ Remote images are blocked — they tell the sender you
372
+ opened this.
373
+ </span>
374
+ )}
375
+
376
+ <button
377
+ type="button"
378
+ className={styles.bodyAction}
379
+ onClick={() => setPlain((v) => !v)}
380
+ >
381
+ <Type size={12} strokeWidth={2} />
382
+ {plain ? 'Formatted' : 'Plain text'}
383
+ </button>
384
+ </div>
385
+
386
+ {plain ? (
387
+ <p className={styles.body}>{entry.body}</p>
388
+ ) : (
389
+ <div className={styles.bodyHtml}>{parse(html, options)}</div>
390
+ )}
391
+ </>
392
+ );
393
+ };
394
+
395
+ const Entry = ({ entry }) => {
396
+ const isNote = entry.direction === 'note';
397
+ const isInbound = entry.direction === 'in';
398
+ const Icon = isNote ? Lock : isInbound ? ArrowDownLeft : ArrowUpRight;
399
+
400
+ const cls = isNote
401
+ ? styles.entryNote
402
+ : isInbound
403
+ ? styles.entryIn
404
+ : styles.entryOut;
405
+
406
+ return (
407
+ <li className={`${styles.entry} ${cls}`}>
408
+ <div className={styles.entryHead}>
409
+ <span className={styles.entryIcon} aria-hidden="true">
410
+ <Icon size={13} strokeWidth={2.5} />
411
+ </span>
412
+
413
+ <span className={styles.author}>
414
+ {entry.author_name || entry.author_email || 'Unknown'}
415
+ </span>
416
+
417
+ {/* The label is the point: never colour alone, and never
418
+ leave "is the customer able to see this?" to inference. */}
419
+ <span className={styles.tag}>
420
+ {isNote
421
+ ? 'Internal note'
422
+ : isInbound
423
+ ? 'From customer'
424
+ : 'Reply sent'}
425
+ </span>
426
+
427
+ {entry.visibility === 'private' && !isNote && (
428
+ <span className={styles.privateTag}>
429
+ <Lock size={10} /> Private
430
+ </span>
431
+ )}
432
+
433
+ {entry.is_first_message && (
434
+ <span className={styles.firstTag}>Original request</span>
435
+ )}
436
+
437
+ {entry.attachment_count > 0 && (
438
+ <span className={styles.attachTag}>
439
+ <Paperclip size={11} /> {entry.attachment_count}
440
+ </span>
441
+ )}
442
+
443
+ {entry.posted_at && (
444
+ <time className={styles.when} dateTime={entry.posted_at}>
445
+ {new Date(entry.posted_at).toLocaleString()}
446
+ </time>
447
+ )}
448
+ </div>
449
+
450
+ <EntryBody entry={entry} />
451
+ </li>
452
+ );
453
+ };
454
+
455
+ export default TicketConversation;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The grid's trailing "Action" column used to render one kind of thing: a
3
+ * shared setting (`update`, `delete`, `view`, …) named by id in the root
4
+ * `settings` array and drawn by DataGrid's own renderSetting.
5
+ *
6
+ * A view often wants one more verb in that same cell — "mark as complete",
7
+ * "mark as spam", "assign to" — which is not a shared setting and never will
8
+ * be, because the endpoint behind it belongs to that one view. The alternative
9
+ * was a second `type: "action"` column sitting beside the Action column, i.e.
10
+ * two action columns per grid.
11
+ *
12
+ * So a root `settings` entry may now ALSO be an inline row-action config, the
13
+ * same object shape an `action` column already accepts:
14
+ *
15
+ * { id, icon, label, show?, confirm?, fetch: { url, method?, pathKey?, … } }
16
+ *
17
+ * and its picker variant, which opens a menu instead of a confirm dialog:
18
+ *
19
+ * { id, icon, label, optionsUrl, optionsParams?, fetch: { …, bodyKey } }
20
+ *
21
+ * Telling the two apart is by SHAPE, not by an opt-in flag: a shared setting
22
+ * is identified by its id and so never carries a `fetch`/`optionsUrl` of its
23
+ * own. Every settings array written before this change therefore partitions
24
+ * entirely into `standard` and behaves exactly as it did.
25
+ */
26
+
27
+ /**
28
+ * True for a root `settings` entry that is an inline row action rather than
29
+ * the id of a shared setting.
30
+ */
31
+ export const isInlineRowActionSetting = (entry) =>
32
+ Boolean(
33
+ entry &&
34
+ typeof entry === 'object' &&
35
+ !Array.isArray(entry) &&
36
+ (entry.optionsUrl ||
37
+ (entry.fetch &&
38
+ typeof entry.fetch === 'object' &&
39
+ entry.fetch.url))
40
+ );
41
+
42
+ /**
43
+ * True for an inline row action that opens a picker (quick-assign) rather than
44
+ * firing straight away.
45
+ */
46
+ export const isPickerRowAction = (config) =>
47
+ Boolean(config && typeof config === 'object' && config.optionsUrl);
48
+
49
+ /**
50
+ * Split a root `settings` array into the inline row actions and the shared
51
+ * settings, preserving each group's declared order.
52
+ *
53
+ * Inline actions come first in the rendered cell: they are the verbs specific
54
+ * to this view, and update/delete keep their familiar position at the end of
55
+ * the row where muscle memory expects them.
56
+ */
57
+ export const splitSettingEntries = (settings) => {
58
+ const entries = Array.isArray(settings) ? settings : [];
59
+
60
+ return {
61
+ inlineActions: entries.filter(isInlineRowActionSetting),
62
+ standard: entries.filter((entry) => !isInlineRowActionSetting(entry)),
63
+ };
64
+ };
65
+
66
+ /**
67
+ * Cache key for a picker's option list. Options are fetched once per grid
68
+ * mount per (url, params) pair — the user list behind a quick-assign menu does
69
+ * not change between two clicks, and refetching it made the menu flash.
70
+ */
71
+ export const pickerOptionsCacheKey = (config) =>
72
+ `${config?.optionsUrl || ''}|${JSON.stringify(config?.optionsParams || {})}`;
73
+
74
+ /**
75
+ * Normalise a dropdown endpoint's answer to `[{ id, label }]`.
76
+ *
77
+ * The CRM's `/ajax/*\/dropdown` endpoints answer `{ data: [...] }`, which
78
+ * CustomFetch hands back as `res.data.data`; a few answer a bare array. Both
79
+ * are accepted so a picker can point at either without a config flag.
80
+ */
81
+ export const normalisePickerOptions = (payload) => {
82
+ const list = Array.isArray(payload)
83
+ ? payload
84
+ : Array.isArray(payload?.data)
85
+ ? payload.data
86
+ : Array.isArray(payload?.data?.data)
87
+ ? payload.data.data
88
+ : [];
89
+
90
+ return list
91
+ .map((option) =>
92
+ option && typeof option === 'object'
93
+ ? {
94
+ id: option.id ?? option.value ?? null,
95
+ label: String(
96
+ option.label ?? option.name ?? option.id ?? ''
97
+ ),
98
+ }
99
+ : { id: option, label: String(option) }
100
+ )
101
+ .filter((option) => option.label !== '');
102
+ };
103
+
104
+ /** Case-insensitive substring match over the option labels. */
105
+ export const filterPickerOptions = (options, search) => {
106
+ const term = String(search || '')
107
+ .trim()
108
+ .toLowerCase();
109
+
110
+ if (!term) {
111
+ return options;
112
+ }
113
+
114
+ return options.filter((option) =>
115
+ String(option.label).toLowerCase().includes(term)
116
+ );
117
+ };