@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.
- package/README.md +1658 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +619 -158
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/AutoGrowCell.jsx +141 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/styles/global-datagrid.css +29 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return
|
|
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
|
|