@visns-studio/visns-components 6.6.3 → 6.15.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 (100) hide show
  1. package/README.md +1658 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +619 -158
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/AutoGrowCell.jsx +141 -0
  32. package/src/components/columns/ColumnRenderers.jsx +53 -7
  33. package/src/components/generic/GenericAuth.jsx +163 -96
  34. package/src/components/generic/GenericDetail.jsx +215 -90
  35. package/src/components/generic/GenericIndex.jsx +20 -47
  36. package/src/components/generic/GenericMain.jsx +5 -0
  37. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  38. package/src/components/generic/StandardModal.jsx +17 -5
  39. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  40. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  41. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  42. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  44. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  45. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  46. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  47. package/src/components/navActive.js +60 -0
  48. package/src/components/notify/desktopNotifications.js +256 -0
  49. package/src/components/sketch/SketchField.jsx +12 -2
  50. package/src/components/sms/SmsComposeModal.jsx +495 -0
  51. package/src/components/sms/SmsInbox.jsx +596 -0
  52. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  53. package/src/components/sms/SmsLineSettings.jsx +854 -0
  54. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  55. package/src/components/sms/smsEndpoints.js +56 -0
  56. package/src/components/sms/smsHelpers.js +1075 -0
  57. package/src/components/sms/smsLiveState.js +132 -0
  58. package/src/components/sms/useSmsLive.js +307 -0
  59. package/src/components/styles/CallQueuePop.module.scss +646 -0
  60. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  61. package/src/components/styles/ClientAuth.module.scss +229 -0
  62. package/src/components/styles/DataGrid.module.scss +10 -0
  63. package/src/components/styles/GenericDetail.module.scss +153 -26
  64. package/src/components/styles/GenericIndex.module.scss +13 -0
  65. package/src/components/styles/GenericMain.module.scss +19 -2
  66. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  67. package/src/components/styles/Login.module.scss +13 -122
  68. package/src/components/styles/LogoutScreen.module.scss +89 -0
  69. package/src/components/styles/Navigation.module.scss +509 -223
  70. package/src/components/styles/Notification.module.scss +103 -0
  71. package/src/components/styles/Reset.module.scss +52 -186
  72. package/src/components/styles/Sms.module.scss +2382 -0
  73. package/src/components/styles/TableFilter.module.scss +118 -98
  74. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  75. package/src/components/styles/Vault.module.scss +1983 -0
  76. package/src/components/styles/Verify.module.scss +57 -180
  77. package/src/components/styles/_authBrandPanel.scss +163 -0
  78. package/src/components/styles/_authShell.scss +369 -0
  79. package/src/components/styles/global-datagrid.css +29 -0
  80. package/src/components/utils/buildEnv.js +67 -0
  81. package/src/components/utils/displayValue.js +94 -0
  82. package/src/components/utils/useDensity.js +345 -9
  83. package/src/components/vault/OtpChip.jsx +208 -0
  84. package/src/components/vault/PasswordGenerator.jsx +145 -0
  85. package/src/components/vault/QrScanner.jsx +326 -0
  86. package/src/components/vault/VaultAccessLog.jsx +225 -0
  87. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  88. package/src/components/vault/VaultEntryForm.jsx +740 -0
  89. package/src/components/vault/VaultManager.jsx +1000 -0
  90. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  91. package/src/components/vault/qrDecode.js +257 -0
  92. package/src/components/vault/useDebouncedValue.js +22 -0
  93. package/src/components/vault/useVaultReveal.js +160 -0
  94. package/src/components/vault/vaultClipboard.js +33 -0
  95. package/src/components/vault/vaultEndpoints.js +40 -0
  96. package/src/components/vault/vaultFit.js +116 -0
  97. package/src/components/vault/vaultHelpers.js +547 -0
  98. package/src/components/vault/vaultNavigation.jsx +58 -0
  99. package/src/components/vault/vaultOtp.js +105 -0
  100. package/src/index.js +233 -1
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Pure helpers for the call-queue components.
3
+ *
4
+ * Split out of CallQueuePop.jsx so they can be imported by `node --test`, which
5
+ * cannot parse JSX. Nothing here touches React or the DOM; CallQueuePop
6
+ * re-exports the lot, so the component's public surface is unchanged.
7
+ */
8
+
9
+ /** Spatie permission that gates the pop, unless `monitorPermission` overrides it. */
10
+ export const MONITOR_PERMISSION = 'Call Queue Monitor';
11
+
12
+ /** Badge text for a queue Zoom sent without a name. */
13
+ export const FALLBACK_QUEUE_NAME = 'Call Queue';
14
+
15
+ /**
16
+ * Does this user hold the monitor permission?
17
+ *
18
+ * `userProfile` comes from the host app's profile endpoint, which eager-loads
19
+ * the Spatie shape `roles[].permissions[].name`. Direct (non-role) permissions
20
+ * are checked too, since Spatie allows assigning a permission straight to a
21
+ * user.
22
+ */
23
+ export const hasMonitorPermission = (
24
+ userProfile,
25
+ permission = MONITOR_PERMISSION
26
+ ) => {
27
+ if (!userProfile) {
28
+ return false;
29
+ }
30
+
31
+ const roles = Array.isArray(userProfile.roles) ? userProfile.roles : [];
32
+
33
+ const viaRole = roles.some((role) =>
34
+ (Array.isArray(role?.permissions) ? role.permissions : []).some(
35
+ (item) => item?.name === permission
36
+ )
37
+ );
38
+
39
+ if (viaRole) {
40
+ return true;
41
+ }
42
+
43
+ const direct = Array.isArray(userProfile.permissions)
44
+ ? userProfile.permissions
45
+ : [];
46
+
47
+ return direct.some((item) => item?.name === permission);
48
+ };
49
+
50
+ /**
51
+ * Reduce any incoming number to Australian local digits (0xxxxxxxxx).
52
+ * `+61412345678` / `61412345678` / `0412 345 678` all collapse to `0412345678`.
53
+ * Display formatting only — the server mirrors this same normalisation for the
54
+ * client match.
55
+ */
56
+ export const toLocalDigits = (value) => {
57
+ if (!value) {
58
+ return '';
59
+ }
60
+
61
+ let digits = String(value).replace(/\D/g, '');
62
+
63
+ // +61 / 0011 61 style country code -> single leading zero.
64
+ if (digits.length === 11 && digits.indexOf('61') === 0) {
65
+ digits = `0${digits.slice(2)}`;
66
+ }
67
+
68
+ return digits;
69
+ };
70
+
71
+ /**
72
+ * Human AU formatting: mobiles as `04xx xxx xxx`, landlines as `0x xxxx xxxx`.
73
+ * libphonenumber-js is not a dependency, so this mirrors the formatter the
74
+ * legacy vendored call pop used (minus its `.replace('61', 0)` bug, which
75
+ * mangled numbers that merely contained "61").
76
+ */
77
+ export const formatAuPhone = (value) => {
78
+ const digits = toLocalDigits(value);
79
+
80
+ if (digits === '') {
81
+ return value ? String(value) : 'Unknown number';
82
+ }
83
+
84
+ if (digits.indexOf('04') === 0) {
85
+ const mobile = digits.match(/^(\d{4})(\d{3})(\d{3})$/);
86
+ if (mobile) {
87
+ return `${mobile[1]} ${mobile[2]} ${mobile[3]}`;
88
+ }
89
+ } else {
90
+ const landline = digits.match(/^(\d{2})(\d{4})(\d{4})$/);
91
+ if (landline) {
92
+ return `${landline[1]} ${landline[2]} ${landline[3]}`;
93
+ }
94
+ }
95
+
96
+ return digits;
97
+ };
98
+
99
+ /**
100
+ * A meeting date as `DD/MM h:mma` (e.g. `21/08 10:00am`). Empty string for an
101
+ * absent or unparseable date, so the caller can just skip it.
102
+ */
103
+ export const formatEventDate = (value) => {
104
+ if (!value) {
105
+ return '';
106
+ }
107
+
108
+ const date = new Date(value);
109
+
110
+ if (Number.isNaN(date.getTime())) {
111
+ return '';
112
+ }
113
+
114
+ const day = String(date.getDate()).padStart(2, '0');
115
+ const month = String(date.getMonth() + 1).padStart(2, '0');
116
+ const hours = date.getHours();
117
+ const minutes = String(date.getMinutes()).padStart(2, '0');
118
+ const suffix = hours < 12 ? 'am' : 'pm';
119
+ const hour12 = hours % 12 === 0 ? 12 : hours % 12;
120
+
121
+ return `${day}/${month} ${hour12}:${minutes}${suffix}`;
122
+ };
123
+
124
+ /** A task due date as `DD/MM/YYYY`; empty string when absent or unparseable. */
125
+ export const formatDueDate = (value) => {
126
+ if (!value) {
127
+ return '';
128
+ }
129
+
130
+ const date = new Date(value);
131
+
132
+ if (Number.isNaN(date.getTime())) {
133
+ return '';
134
+ }
135
+
136
+ const day = String(date.getDate()).padStart(2, '0');
137
+ const month = String(date.getMonth() + 1).padStart(2, '0');
138
+
139
+ return `${day}/${month}/${date.getFullYear()}`;
140
+ };
141
+
142
+ /**
143
+ * The `dataId` the Active Call workspace (`/call/:dataId`) expects: it rebuilds
144
+ * the number it searches on as `'0' + dataId.slice(2)`, so the param has to be
145
+ * the AU number in `61xxxxxxxxx` form. Anything that does not reduce to a local
146
+ * `0…` number has no workspace to open, and yields ''.
147
+ */
148
+ export const toCallWorkspaceId = (value) => {
149
+ const digits = toLocalDigits(value);
150
+
151
+ if (digits.indexOf('0') !== 0 || digits.length < 9) {
152
+ return '';
153
+ }
154
+
155
+ return `61${digits.slice(1)}`;
156
+ };
157
+
158
+ /**
159
+ * Tasks index filtered to one client: `/tasks/:param_task_type_id/:param_company_id`.
160
+ * Type 0 is the grid's own "All Current Tasks" option, which is the closest
161
+ * thing to "every open task for this client" the route can express — types 1-4
162
+ * each narrow it further.
163
+ */
164
+ export const defaultClientTasksUrl = (clientId) => `/tasks/0/${clientId}`;
165
+
166
+ /** Default popup target for a task row. */
167
+ export const defaultTaskUrl = (task) => `/tasks/detail/${task?.id}`;
168
+
169
+ /** Default detail route for a matched client. */
170
+ export const defaultClientUrl = (client) => `/clients/${client?.id}`;
171
+
172
+ /** The client fields shown as label/value rows, in card order. */
173
+ export const CLIENT_DETAIL_FIELDS = [
174
+ { key: 'adviser', label: 'Adviser' },
175
+ { key: 'coding', label: 'Coding' },
176
+ { key: 'age', label: 'Age' },
177
+ { key: 'city', label: 'City' },
178
+ ];
179
+
180
+ /**
181
+ * Detail rows for a client block, with the empty ones dropped — a card only
182
+ * ever shows what the CRM actually knows, never a row with a blank value.
183
+ */
184
+ export const clientDetails = (client) => {
185
+ if (!client || typeof client !== 'object') {
186
+ return [];
187
+ }
188
+
189
+ return CLIENT_DETAIL_FIELDS.map(({ key, label }) => ({
190
+ label,
191
+ value: client[key],
192
+ })).filter(
193
+ (row) =>
194
+ row.value !== null &&
195
+ row.value !== undefined &&
196
+ String(row.value).trim() !== ''
197
+ );
198
+ };
199
+
200
+ /** Seconds since `startedAt` rendered as m:ss (never negative). */
201
+ export const formatElapsed = (startedAt, now) => {
202
+ const started = new Date(startedAt).getTime();
203
+
204
+ if (Number.isNaN(started)) {
205
+ return '0:00';
206
+ }
207
+
208
+ const seconds = Math.max(0, Math.floor((now - started) / 1000));
209
+ const minutes = Math.floor(seconds / 60);
210
+ const remainder = seconds % 60;
211
+
212
+ return `${minutes}:${String(remainder).padStart(2, '0')}`;
213
+ };
214
+
215
+ /**
216
+ * Coerce a payload of unknown shape (snapshot endpoint or broadcast) into the
217
+ * component's call shape. Snake_case and camelCase keys are both accepted so a
218
+ * Laravel resource can be passed through untouched.
219
+ */
220
+ export const normaliseCall = (raw) => {
221
+ if (!raw || typeof raw !== 'object') {
222
+ return null;
223
+ }
224
+
225
+ const callId = raw.callId ?? raw.call_id ?? raw.id;
226
+
227
+ if (callId === undefined || callId === null || callId === '') {
228
+ return null;
229
+ }
230
+
231
+ // The Zoom call queue this rang on. `queueId` keys the pickup-code lookup
232
+ // and may legitimately be absent; `queueName` is what the badge prints.
233
+ const rawQueueId = raw.queueId ?? raw.queue_id ?? null;
234
+ const queueId =
235
+ rawQueueId === null || rawQueueId === undefined || rawQueueId === ''
236
+ ? null
237
+ : String(rawQueueId);
238
+
239
+ const rawQueueName = raw.queueName ?? raw.queue_name ?? '';
240
+ const queueName = String(rawQueueName).trim() || FALLBACK_QUEUE_NAME;
241
+
242
+ return {
243
+ callId: String(callId),
244
+ queueId,
245
+ queueName,
246
+ callerNumber: raw.callerNumber ?? raw.caller_number ?? raw.from ?? '',
247
+ callerName: raw.callerName ?? raw.caller_name ?? '',
248
+ client: raw.client ?? null,
249
+ // Pre-supplied open tasks. Only demo calls carry these; a real call
250
+ // leaves it null and the expansion fetches the drill-down endpoint.
251
+ tasks: Array.isArray(raw.tasks) ? raw.tasks : null,
252
+ startedAt: raw.startedAt ?? raw.started_at ?? new Date().toISOString(),
253
+ status: 'ringing',
254
+ isDemo: raw.isDemo === true || raw.is_demo === true,
255
+ leaving: false,
256
+ };
257
+ };
258
+
259
+ /**
260
+ * Coerce the snapshot's `pickup_codes` block into a plain `{ queueId: code }`
261
+ * map, dropping anything that is not a non-empty string on both sides. A queue
262
+ * absent from the map has no code, which is what suppresses its Pick up button.
263
+ */
264
+ export const normalisePickupCodes = (raw) => {
265
+ if (!raw || typeof raw !== 'object') {
266
+ return {};
267
+ }
268
+
269
+ const codes = {};
270
+
271
+ Object.keys(raw).forEach((queueId) => {
272
+ const code = raw[queueId];
273
+
274
+ if (queueId !== '' && typeof code === 'string' && code.trim() !== '') {
275
+ codes[queueId] = code.trim();
276
+ }
277
+ });
278
+
279
+ return codes;
280
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pure helpers for the call-queue settings page.
3
+ *
4
+ * Split out of CallQueueSettings.jsx so they can be imported by `node --test`,
5
+ * which cannot parse JSX. Nothing here touches React or the DOM; the component
6
+ * re-exports the lot, so its public surface is unchanged.
7
+ */
8
+
9
+ /**
10
+ * Zoom's pickup code rules, mirrored from CallQueueSettingsController so a typo
11
+ * is caught before the round trip. Returns an error string, or '' when valid.
12
+ */
13
+ export const validateCode = (value) => {
14
+ const code = String(value ?? '').trim();
15
+
16
+ if (code === '') return '';
17
+ if (!/^\d{4}$/.test(code)) return 'Must be 4 digits.';
18
+ if (code[0] === '0') return 'Cannot start with 0.';
19
+ if (/^(\d)\1{3}$/.test(code)) return 'Cannot repeat one digit.';
20
+
21
+ const steps = [1, 2, 3].map((i) => Number(code[i]) - Number(code[i - 1]));
22
+
23
+ if (steps.every((s) => s === 1) || steps.every((s) => s === -1)) {
24
+ return 'Cannot be a sequence.';
25
+ }
26
+
27
+ return '';
28
+ };
29
+
30
+ /** Digits only — the `*` is the dial prefix, never part of the stored code. */
31
+ export const cleanCode = (value) =>
32
+ String(value ?? '')
33
+ .replace(/[^\d]/g, '')
34
+ .slice(0, 4);
35
+
36
+ /**
37
+ * Human AU formatting for the queue's own number, using the same digit logic as
38
+ * the call pop's formatter (callQueue/CallQueuePop.jsx): a +61 / 61 country code
39
+ * collapses to a single leading zero, then mobiles read as `04xx xxx xxx` and
40
+ * landlines as `(0x) xxxx xxxx`. Anything that does not fit a known shape is
41
+ * handed back untouched rather than mangled.
42
+ */
43
+ export const formatAuNumber = (value) => {
44
+ if (!value) return '';
45
+
46
+ let digits = String(value).replace(/\D/g, '');
47
+
48
+ if (digits.length === 11 && digits.indexOf('61') === 0) {
49
+ digits = `0${digits.slice(2)}`;
50
+ }
51
+
52
+ if (digits.indexOf('04') === 0) {
53
+ const mobile = digits.match(/^(\d{4})(\d{3})(\d{3})$/);
54
+
55
+ if (mobile) return `${mobile[1]} ${mobile[2]} ${mobile[3]}`;
56
+ } else if (/^1[38]00/.test(digits) && digits.length === 10) {
57
+ // 13/1800 service numbers group as 1300 123 456.
58
+ return `${digits.slice(0, 4)} ${digits.slice(4, 7)} ${digits.slice(7)}`;
59
+ } else {
60
+ const landline = digits.match(/^(\d{2})(\d{4})(\d{4})$/);
61
+
62
+ if (landline) return `(${landline[1]}) ${landline[2]} ${landline[3]}`;
63
+ }
64
+
65
+ return String(value);
66
+ };
67
+
68
+ /** Zoom sends the status lower case ("active"); the pill shows it title case. */
69
+ export const titleCase = (value) => {
70
+ const text = String(value ?? '').trim();
71
+
72
+ if (text === '') return 'Unknown';
73
+
74
+ return text.charAt(0).toUpperCase() + text.slice(1);
75
+ };
@@ -0,0 +1,141 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * The measuring wrapper behind a column's `"autoGrow": true`.
5
+ *
6
+ * Why it exists
7
+ * -------------
8
+ * In tablet mode (and in `large` density generally) the grid runs with a
9
+ * FIXED `rowHeight` — see the density table in `utils/useDensity.js` and the
10
+ * comment above `rowHeight={grid.rowHeight}` in `DataGrid.jsx`. A fixed height
11
+ * is what keeps action buttons from being clipped after an auto-refresh on an
12
+ * idle tab, because Inovua's alternative (`rowHeight={null}`) measures every
13
+ * row with a lazily-scheduled rAF, which a background-throttled tab never runs.
14
+ *
15
+ * The cost of that trade is that a cell whose content genuinely needs three
16
+ * lines — a `stageCounter` column with enough chips to wrap twice — gets cut
17
+ * off at the row's height. `autoGrow` buys back JUST those rows: this wrapper
18
+ * measures the content's natural height and hands it up, and the grid raises
19
+ * that ONE row to fit. Every other row keeps the fixed height, so the property
20
+ * that made the fixed height worth having (no measurement in the common path)
21
+ * is preserved.
22
+ *
23
+ * How the measurement stays honest
24
+ * --------------------------------
25
+ * The cell itself is height-clamped (`.InovuaReactDataGrid__cell` gets an
26
+ * inline `height` from the row's height, and `global-datagrid.css` gives it
27
+ * `overflow: hidden`), so measuring the CELL would only ever report the clamp
28
+ * back. This `div` is a plain block inside the cell's content wrapper with no
29
+ * height of its own, so its `scrollHeight` is the content's natural height
30
+ * whatever the cell around it is doing.
31
+ *
32
+ * The number reported is a ROW height, not a content height: the owning cell's
33
+ * vertical padding and borders are read off `getComputedStyle` and added, so
34
+ * the figure tracks `--grid-cell-padding` and the density that sets it instead
35
+ * of a constant that would drift the moment either changed.
36
+ *
37
+ * @param {object} props
38
+ * @param {*} props.rowId id of the row this cell belongs to; the
39
+ * key the grid stores the height under.
40
+ * @param {Function} props.onMeasure `(rowId, heightPx, force) => void`
41
+ * @param {import('react').ReactNode} props.children the column's own render.
42
+ */
43
+ const AutoGrowCell = ({ rowId, onMeasure, children }) => {
44
+ const nodeRef = useRef(null);
45
+ // Last height handed up, so a ResizeObserver that fires on every column
46
+ // resize does not re-report a height nothing has changed about.
47
+ const lastReportedRef = useRef(null);
48
+
49
+ useEffect(() => {
50
+ const node = nodeRef.current;
51
+
52
+ if (
53
+ !node ||
54
+ rowId === undefined ||
55
+ rowId === null ||
56
+ typeof onMeasure !== 'function'
57
+ ) {
58
+ return undefined;
59
+ }
60
+
61
+ /**
62
+ * Padding + borders of the cell this content sits in. Measured rather
63
+ * than assumed: `--grid-cell-padding` is density-driven and a caller
64
+ * can override it per grid.
65
+ */
66
+ const cellChrome = () => {
67
+ if (
68
+ typeof window === 'undefined' ||
69
+ typeof window.getComputedStyle !== 'function' ||
70
+ typeof node.closest !== 'function'
71
+ ) {
72
+ return 0;
73
+ }
74
+
75
+ const cell = node.closest('.InovuaReactDataGrid__cell');
76
+
77
+ if (!cell) {
78
+ return 0;
79
+ }
80
+
81
+ const cs = window.getComputedStyle(cell);
82
+ const sum =
83
+ (parseFloat(cs.paddingTop) || 0) +
84
+ (parseFloat(cs.paddingBottom) || 0) +
85
+ (parseFloat(cs.borderTopWidth) || 0) +
86
+ (parseFloat(cs.borderBottomWidth) || 0);
87
+
88
+ return Number.isFinite(sum) ? sum : 0;
89
+ };
90
+
91
+ /**
92
+ * @param {boolean} force report even when the height has not moved —
93
+ * used on mount and whenever the row under this cell changes,
94
+ * because the grid keys heights by row index and an unchanged
95
+ * height at a NEW index still has to be re-applied.
96
+ */
97
+ const report = (force) => {
98
+ if (!nodeRef.current) {
99
+ return;
100
+ }
101
+
102
+ const natural = nodeRef.current.scrollHeight || 0;
103
+
104
+ if (!natural) {
105
+ return;
106
+ }
107
+
108
+ const height = Math.ceil(natural + cellChrome());
109
+ const last = lastReportedRef.current;
110
+
111
+ if (!force && last !== null && Math.abs(last - height) <= 1) {
112
+ return;
113
+ }
114
+
115
+ lastReportedRef.current = height;
116
+ onMeasure(rowId, height, !!force);
117
+ };
118
+
119
+ report(true);
120
+
121
+ // No ResizeObserver (jsdom, older Safari): the mount measurement above
122
+ // still applies, the cell just will not follow later reflows.
123
+ if (typeof ResizeObserver === 'undefined') {
124
+ return undefined;
125
+ }
126
+
127
+ const observer = new ResizeObserver(() => report(false));
128
+
129
+ observer.observe(node);
130
+
131
+ return () => observer.disconnect();
132
+ }, [rowId, onMeasure]);
133
+
134
+ return (
135
+ <div ref={nodeRef} className="autoGrowCell" style={{ width: '100%' }}>
136
+ {children}
137
+ </div>
138
+ );
139
+ };
140
+
141
+ export default AutoGrowCell;
@@ -14,6 +14,7 @@ import {
14
14
  DEFAULT_INTELLIGENT_SORTING_CONFIG,
15
15
  } from '../../utils/relationshipSortingUtils';
16
16
  import { formatCellContent } from '../generic/shared/formatters';
17
+ import { arrayCountFrom } from '../utils/displayValue';
17
18
 
18
19
  // Stage Toggle Utility Functions
19
20
  const buildToggleUrl = (urlTemplate, stageItem, rowData) => {
@@ -380,18 +381,63 @@ export const renderDateTimeColumn = ({
380
381
  };
381
382
  };
382
383
 
383
- // Array Count Column Renderer
384
+ /**
385
+ * Array Count Column Renderer
386
+ *
387
+ * A count is a quantity, and a bare digit in a text column reads as a short
388
+ * piece of text — it takes a second look to tell "3" the count from "3" the
389
+ * label. The number goes in a small neutral pill so the eye can pick the
390
+ * column out and compare rows down it, and an empty list says so with a muted
391
+ * dash rather than an empty cell (an empty cell is what a column shows when it
392
+ * has no answer; here we have one — none).
393
+ *
394
+ * `arrayCountFrom` is what makes the zero case reachable at all: the old
395
+ * truthiness check sent `[]`, `"[]"` and a missing field down the same branch,
396
+ * and `.length` on a JSON string that was never decoded returned the character
397
+ * count.
398
+ */
384
399
  export const renderArrayCountColumn = ({ column, commonProps }) => {
385
400
  return {
386
401
  ...commonProps,
387
402
  name: `${column.type}.${column.id}`,
388
- sortable: true,
403
+ // Was hard-coded `true`. A count column is derived from a JSON field,
404
+ // so server-side ordering on it is lexicographic nonsense as often as
405
+ // not; a config can now turn the header control off. Default unchanged.
406
+ sortable: column.sortable ?? true,
389
407
  render: ({ data }) => {
390
- if (data && data[column.id]) {
391
- return data[column.id].length;
392
- } else {
393
- return null;
408
+ const count = arrayCountFrom(data ? data[column.id] : null);
409
+
410
+ if (count === 0) {
411
+ return (
412
+ <span style={{ color: 'var(--muted-color, #6e7276)' }}>
413
+ —
414
+ </span>
415
+ );
394
416
  }
417
+
418
+ return (
419
+ <span
420
+ style={{
421
+ display: 'inline-flex',
422
+ alignItems: 'center',
423
+ justifyContent: 'center',
424
+ minWidth: '22px',
425
+ height: '20px',
426
+ padding: '0 7px',
427
+ boxSizing: 'border-box',
428
+ borderRadius: '999px',
429
+ border: '1px solid var(--border-color, #e1e1e1)',
430
+ background: 'var(--hover-color, rgba(0, 0, 0, 0.04))',
431
+ color: 'var(--paragraph-color, #221e33)',
432
+ fontSize: '0.75rem',
433
+ fontWeight: 600,
434
+ lineHeight: 1,
435
+ fontVariantNumeric: 'tabular-nums',
436
+ }}
437
+ >
438
+ {count}
439
+ </span>
440
+ );
395
441
  },
396
442
  };
397
443
  };
@@ -2877,7 +2923,7 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
2877
2923
  rawValue !== null &&
2878
2924
  rawValue !== undefined
2879
2925
  ) {
2880
- tooltipContent = `Stage ${i + 1})`;
2926
+ tooltipContent = `Stage ${i + 1}`;
2881
2927
  }
2882
2928
  }
2883
2929