@visns-studio/visns-components 6.24.4 → 6.26.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/package.json +4 -2
- package/src/components/Autocomplete.jsx +189 -119
- package/src/components/DataGrid.jsx +472 -31
- package/src/components/Navigation.jsx +475 -51
- package/src/components/auth/ClientAuthFrame.jsx +5 -0
- package/src/components/auth/ClientAuthScreen.jsx +29 -0
- package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
- package/src/components/callQueue/CallQueuePop.jsx +713 -90
- package/src/components/callQueue/CallQueueSettings.jsx +308 -146
- package/src/components/callQueue/callPopStatus.js +236 -0
- package/src/components/callQueue/callQueueHelpers.js +284 -2
- package/src/components/columns/ColumnRenderers.jsx +3 -46
- package/src/components/columns/StackedRow.jsx +186 -0
- package/src/components/controls/DataGridSearch.jsx +110 -2
- package/src/components/controls/DataGridSortSheet.jsx +155 -0
- package/src/components/generic/GenericAuth.jsx +50 -18
- package/src/components/generic/GenericDashboard.jsx +20 -1
- package/src/components/generic/GenericDetail.jsx +446 -259
- package/src/components/mapboxSearchBox.js +640 -0
- package/src/components/navBadges.js +63 -1
- package/src/components/navDrawer.js +147 -0
- package/src/components/sms/SmsThreadPanel.jsx +34 -6
- package/src/components/sms/smsHelpers.js +15 -0
- package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
- package/src/components/styles/CallQueuePop.module.scss +29 -0
- package/src/components/styles/CallQueueSettings.module.scss +93 -0
- package/src/components/styles/ClientAuth.module.scss +39 -0
- package/src/components/styles/DataGrid.module.scss +158 -5
- package/src/components/styles/Field.module.scss +52 -1
- package/src/components/styles/Form.module.scss +82 -0
- package/src/components/styles/GenericClientPortal.module.scss +72 -20
- package/src/components/styles/GenericDashboard.module.scss +50 -0
- package/src/components/styles/GenericDetail.module.scss +63 -1
- package/src/components/styles/GenericDynamic.module.scss +23 -0
- package/src/components/styles/GenericFormBuilder.module.scss +11 -0
- package/src/components/styles/GenericIndex.module.scss +6 -1
- package/src/components/styles/Navigation.module.scss +460 -7
- package/src/components/styles/Sms.module.scss +92 -0
- package/src/components/styles/StackedRow.module.scss +182 -0
- package/src/components/styles/TicketConversation.module.scss +76 -0
- package/src/components/styles/Vault.module.scss +192 -0
- package/src/components/styles/density.css +10 -0
- package/src/components/styles/global-datagrid.css +163 -0
- package/src/components/styles/global.css +20 -0
- package/src/components/tickets/TicketConversation.jsx +13 -8
- package/src/components/utils/ConfirmDialog.js +22 -3
- package/src/components/utils/cardLayout.js +666 -0
- package/src/components/utils/contactChannels.js +130 -0
- package/src/components/utils/editPlacement.js +95 -0
- package/src/components/utils/useDensity.js +303 -7
- package/src/index.js +42 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { isBlankValue } from '../utils/displayValue';
|
|
2
|
+
import { channelOf, fieldKey } from '../utils/cardLayout';
|
|
3
|
+
import {
|
|
4
|
+
formatPhoneNumber,
|
|
5
|
+
mailtoHref,
|
|
6
|
+
telHref,
|
|
7
|
+
} from '../utils/contactChannels';
|
|
8
|
+
import styles from '../styles/StackedRow.module.scss';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One grid row, drawn stacked instead of spread across columns.
|
|
12
|
+
*
|
|
13
|
+
* THIS IS A CELL, not a replacement for the row. It renders inside the single
|
|
14
|
+
* synthetic `__card` column the DataGrid mounts below 640px, which is what
|
|
15
|
+
* keeps everything else — the data source, the pager, selection, and above all
|
|
16
|
+
* `onRowClick`'s cell-index link resolution — working with nothing rewritten.
|
|
17
|
+
*
|
|
18
|
+
* IT FORMATS NOTHING ITSELF. Every value except a phone number comes back from
|
|
19
|
+
* `renderCell`, which calls the descriptor the column was already built into,
|
|
20
|
+
* so a currency on a card and the same currency in the table are the same
|
|
21
|
+
* string produced by the same code. The one exception is a channel: the phone
|
|
22
|
+
* column renderer formats a number and produces no `tel:`, which is the entire
|
|
23
|
+
* point of the treatment, so the anchor is built here from the shared
|
|
24
|
+
* `formatPhoneNumber`.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} props
|
|
27
|
+
* @param {object} props.row the data row
|
|
28
|
+
* @param {object} props.mapping from `deriveCardLayout`
|
|
29
|
+
* @param {(column: object, row: object) => any} props.renderCell
|
|
30
|
+
* @param {(row: object) => any} [props.renderActions] the rail's controls
|
|
31
|
+
*/
|
|
32
|
+
const StackedRow = ({ row, mapping, renderCell, renderActions }) => {
|
|
33
|
+
// Belt and braces: card mode is hard-disabled for a grouped grid, and a
|
|
34
|
+
// tree parent is a summary with a fabricated id rather than a record. If
|
|
35
|
+
// either ever reaches here, draw nothing rather than a card claiming to be
|
|
36
|
+
// one of them.
|
|
37
|
+
if (!row || row.__treeParent || row.__group || !mapping) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The raw field value, for the two decisions a rendered node cannot
|
|
43
|
+
* answer: is there anything here at all, and what goes in the `href`.
|
|
44
|
+
*/
|
|
45
|
+
const rawValue = (column) => {
|
|
46
|
+
if (!column) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const path = Array.isArray(column.id)
|
|
51
|
+
? column.id
|
|
52
|
+
: String(column.id ?? '').split('.');
|
|
53
|
+
|
|
54
|
+
return path.reduce(
|
|
55
|
+
(value, segment) =>
|
|
56
|
+
value === null || value === undefined ? value : value[segment],
|
|
57
|
+
row
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A rendered value, or `null` when the field is empty on this row. */
|
|
62
|
+
const cell = (column) => {
|
|
63
|
+
if (!column) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const node = renderCell ? renderCell(column, row) : null;
|
|
68
|
+
|
|
69
|
+
return node === undefined || node === false ? null : node;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const title = Array.isArray(mapping.title)
|
|
73
|
+
? // A NAME PAIR IS THE ONE PLACE THE RENDERERS ARE BYPASSED. Each of
|
|
74
|
+
// them wraps its value in a `width: 100%` ellipsis box, so two of
|
|
75
|
+
// them side by side each take the whole line and the surname is
|
|
76
|
+
// pushed off. A person's name has no formatting to lose.
|
|
77
|
+
mapping.title
|
|
78
|
+
.map((column) => rawValue(column))
|
|
79
|
+
.filter((value) => !isBlankValue(value))
|
|
80
|
+
.join(' ')
|
|
81
|
+
: cell(mapping.title);
|
|
82
|
+
|
|
83
|
+
const referenceValue = rawValue(mapping.reference);
|
|
84
|
+
const badge = cell(mapping.badge);
|
|
85
|
+
const subtitle = cell(mapping.subtitle);
|
|
86
|
+
const actions = renderActions ? renderActions(row) : null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A channel line: the formatted number or address, as an anchor.
|
|
90
|
+
*
|
|
91
|
+
* `stopPropagation` on the anchor, not `preventDefault` — the tap has to
|
|
92
|
+
* reach the dialer, it just must not also open the record behind it.
|
|
93
|
+
*/
|
|
94
|
+
const channelLine = (column, index) => {
|
|
95
|
+
const value = rawValue(column);
|
|
96
|
+
|
|
97
|
+
if (isBlankValue(value)) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const kind = channelOf(column);
|
|
102
|
+
const href = kind === 'email' ? mailtoHref(value) : telHref(value);
|
|
103
|
+
const text =
|
|
104
|
+
kind === 'email' ? String(value).trim() : formatPhoneNumber(value);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
// Keyed on the INDEX as well as the field: a view can declare two
|
|
108
|
+
// columns with the same last id segment (brandingProfiles has
|
|
109
|
+
// three `['colors']` columns) and React would drop all but one.
|
|
110
|
+
<div className={styles.line} key={`channel-${index}-${fieldKey(column)}`}>
|
|
111
|
+
{href ? (
|
|
112
|
+
<a
|
|
113
|
+
className={styles.channel}
|
|
114
|
+
href={href}
|
|
115
|
+
onClick={(event) => event.stopPropagation()}
|
|
116
|
+
>
|
|
117
|
+
{text}
|
|
118
|
+
</a>
|
|
119
|
+
) : (
|
|
120
|
+
<span className={styles.lineValue}>{text}</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A meta line: the column's label, then its value.
|
|
128
|
+
*
|
|
129
|
+
* The label is carried because a bare date under a bare name is a fact
|
|
130
|
+
* nobody can read — "Closing 14/09/2026" and "14/09/2026" are not the same
|
|
131
|
+
* line. A blank field collapses to nothing and the ROW STAYS THE SAME
|
|
132
|
+
* HEIGHT, which is what stops a list of records looking like a ransom note.
|
|
133
|
+
*/
|
|
134
|
+
const metaLine = (column, index) => {
|
|
135
|
+
const value = cell(column);
|
|
136
|
+
|
|
137
|
+
if (value === null || isBlankValue(rawValue(column))) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className={styles.line} key={`meta-${index}-${fieldKey(column)}`}>
|
|
143
|
+
{column.label ? (
|
|
144
|
+
<span className={styles.lineLabel}>{column.label}</span>
|
|
145
|
+
) : null}
|
|
146
|
+
<span className={styles.lineValue}>{value}</span>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className={styles.card}>
|
|
153
|
+
<div className={styles.main}>
|
|
154
|
+
<div className={styles.titleRow}>
|
|
155
|
+
{isBlankValue(referenceValue) ? null : (
|
|
156
|
+
<span className={styles.reference}>
|
|
157
|
+
{String(referenceValue)}
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
<div className={styles.title}>{title}</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{subtitle === null ||
|
|
164
|
+
isBlankValue(rawValue(mapping.subtitle)) ? null : (
|
|
165
|
+
<div className={styles.subtitle}>{subtitle}</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{(mapping.channels || []).map(channelLine)}
|
|
169
|
+
{(mapping.meta || []).map(metaLine)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{badge === null && !actions ? null : (
|
|
173
|
+
<div className={styles.rail}>
|
|
174
|
+
{badge === null ? null : (
|
|
175
|
+
<div className={styles.badge}>{badge}</div>
|
|
176
|
+
)}
|
|
177
|
+
{actions ? (
|
|
178
|
+
<div className={styles.actions}>{actions}</div>
|
|
179
|
+
) : null}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export default StackedRow;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
1
2
|
import { useNavigate } from 'react-router-dom';
|
|
2
3
|
import { openRecordWindow } from '../utils/popupWindow';
|
|
3
4
|
import { toast } from 'react-toastify';
|
|
@@ -7,9 +8,12 @@ import {
|
|
|
7
8
|
Plus,
|
|
8
9
|
Search,
|
|
9
10
|
ArrowUpDown,
|
|
11
|
+
Rows3,
|
|
12
|
+
Table,
|
|
10
13
|
Upload,
|
|
11
14
|
} from 'lucide-react';
|
|
12
15
|
import Download from '../Download';
|
|
16
|
+
import DataGridSortSheet from './DataGridSortSheet';
|
|
13
17
|
import styles from '../styles/DataGrid.module.scss';
|
|
14
18
|
|
|
15
19
|
const DataGridSearch = ({
|
|
@@ -26,8 +30,21 @@ const DataGridSearch = ({
|
|
|
26
30
|
pageSetting,
|
|
27
31
|
// Bulk upload
|
|
28
32
|
onBulkUploadClick,
|
|
33
|
+
// Card mode. `cardCapable` is "this grid is narrow AND a mapping was
|
|
34
|
+
// derived" — the only condition under which either of these two controls
|
|
35
|
+
// means anything, so above 640px nothing below renders and the toolbar is
|
|
36
|
+
// the one it has always been.
|
|
37
|
+
cardCapable = false,
|
|
38
|
+
cardMode = false,
|
|
39
|
+
onCardModeChange,
|
|
40
|
+
columns,
|
|
41
|
+
columnsMetadata,
|
|
42
|
+
sortBy,
|
|
43
|
+
sort,
|
|
44
|
+
onSort,
|
|
29
45
|
}) => {
|
|
30
46
|
const navigate = useNavigate();
|
|
47
|
+
const [sortSheetOpen, setSortSheetOpen] = useState(false);
|
|
31
48
|
|
|
32
49
|
const handleChangeSearch = (e) => {
|
|
33
50
|
const { value } = e.target;
|
|
@@ -90,6 +107,60 @@ const DataGridSearch = ({
|
|
|
90
107
|
navigate(sortUrl, { state: newState });
|
|
91
108
|
};
|
|
92
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The two card-mode controls, and they only exist below 640px.
|
|
112
|
+
*
|
|
113
|
+
* SORT replaces the job the column headers were doing before
|
|
114
|
+
* `showHeader={false}` took them away. THE TABLE TOGGLE is the escape
|
|
115
|
+
* hatch for the job they were doing that this pass does NOT replace: the
|
|
116
|
+
* filter row. A per-column filter needs per-column chrome and there is one
|
|
117
|
+
* column here, so a reader who needs to filter presses Table and gets the
|
|
118
|
+
* whole grid back — sideways scroll, filter row and all — for as long as
|
|
119
|
+
* this tab is open.
|
|
120
|
+
*
|
|
121
|
+
* Drawn as icon squares rather than labelled buttons because they sit
|
|
122
|
+
* beside Add New on a 340px screen, and three labelled buttons on that row
|
|
123
|
+
* wrap onto two lines.
|
|
124
|
+
*/
|
|
125
|
+
const renderCardControls = () => {
|
|
126
|
+
if (!cardCapable) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
{cardMode ? (
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className={styles.cardControl}
|
|
136
|
+
aria-label="Sort"
|
|
137
|
+
title="Sort"
|
|
138
|
+
onClick={() => setSortSheetOpen(true)}
|
|
139
|
+
>
|
|
140
|
+
<ArrowUpDown strokeWidth={2} size={16} />
|
|
141
|
+
</button>
|
|
142
|
+
) : null}
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className={styles.cardControl}
|
|
146
|
+
aria-pressed={!cardMode}
|
|
147
|
+
aria-label={cardMode ? 'Show as a table' : 'Show as cards'}
|
|
148
|
+
title={cardMode ? 'Show as a table' : 'Show as cards'}
|
|
149
|
+
onClick={() =>
|
|
150
|
+
onCardModeChange &&
|
|
151
|
+
onCardModeChange(cardMode ? 'table' : 'cards')
|
|
152
|
+
}
|
|
153
|
+
>
|
|
154
|
+
{cardMode ? (
|
|
155
|
+
<Table strokeWidth={2} size={16} />
|
|
156
|
+
) : (
|
|
157
|
+
<Rows3 strokeWidth={2} size={16} />
|
|
158
|
+
)}
|
|
159
|
+
</button>
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
93
164
|
const renderCreateButton = () => {
|
|
94
165
|
if (form.createDisable && form.createDisable === true) {
|
|
95
166
|
return null;
|
|
@@ -209,16 +280,47 @@ const DataGridSearch = ({
|
|
|
209
280
|
const isSearchEnabled = !(form.searchDisable === true);
|
|
210
281
|
const isCreateDisabled = form.createDisable === true;
|
|
211
282
|
|
|
212
|
-
|
|
283
|
+
const sortSheet =
|
|
284
|
+
sortSheetOpen && cardMode ? (
|
|
285
|
+
<DataGridSortSheet
|
|
286
|
+
columns={columns}
|
|
287
|
+
columnsMetadata={columnsMetadata}
|
|
288
|
+
sortBy={sortBy}
|
|
289
|
+
sort={sort}
|
|
290
|
+
onSort={onSort}
|
|
291
|
+
onClose={() => setSortSheetOpen(false)}
|
|
292
|
+
/>
|
|
293
|
+
) : null;
|
|
294
|
+
|
|
295
|
+
// Don't render anything if both search and create are disabled — unless
|
|
296
|
+
// card mode is on, in which case this is the ONLY place Sort and Table can
|
|
297
|
+
// live and a grid without them cannot be sorted or filtered at all.
|
|
213
298
|
if (!isSearchEnabled && isCreateDisabled) {
|
|
214
|
-
|
|
299
|
+
if (!cardCapable) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div className={styles.actionButtonsContainer}>
|
|
305
|
+
<div className={styles.actionButtonsStandalone}>
|
|
306
|
+
{renderCardControls()}
|
|
307
|
+
</div>
|
|
308
|
+
{sortSheet}
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
215
311
|
}
|
|
216
312
|
|
|
217
313
|
// If search is disabled but create button is enabled, render only the button with its own container
|
|
218
314
|
if (!isSearchEnabled && !isCreateDisabled) {
|
|
219
315
|
return (
|
|
220
316
|
<div className={styles.actionButtonsContainer}>
|
|
317
|
+
{cardCapable ? (
|
|
318
|
+
<div className={styles.actionButtonsStandalone}>
|
|
319
|
+
{renderCardControls()}
|
|
320
|
+
</div>
|
|
321
|
+
) : null}
|
|
221
322
|
{renderCreateButton()}
|
|
323
|
+
{sortSheet}
|
|
222
324
|
</div>
|
|
223
325
|
);
|
|
224
326
|
}
|
|
@@ -254,8 +356,14 @@ const DataGridSearch = ({
|
|
|
254
356
|
/>
|
|
255
357
|
</div>
|
|
256
358
|
<div className={styles.actionButtonsWrapper}>
|
|
359
|
+
{cardCapable ? (
|
|
360
|
+
<div className={styles.actionButtonsStandalone}>
|
|
361
|
+
{renderCardControls()}
|
|
362
|
+
</div>
|
|
363
|
+
) : null}
|
|
257
364
|
{!isCreateDisabled && renderCreateButton()}
|
|
258
365
|
</div>
|
|
366
|
+
{sortSheet}
|
|
259
367
|
</div>
|
|
260
368
|
);
|
|
261
369
|
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ArrowDown, ArrowUp, Check } from 'lucide-react';
|
|
2
|
+
import StandardModal from '../generic/StandardModal';
|
|
3
|
+
import { isColumnSortable } from '../../utils/columnsMetadataUtils';
|
|
4
|
+
import styles from '../styles/DataGrid.module.scss';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sorting, with the column headers gone.
|
|
8
|
+
*
|
|
9
|
+
* In card mode the grid is drawn with `showHeader={false}`, which takes the
|
|
10
|
+
* column headers AND the filter row away in one prop — and a header click is
|
|
11
|
+
* how a grid has always been sorted. It is not the only caller of
|
|
12
|
+
* `handleSort({ id, dir })` though: that writes `sortBy`/`sort` into
|
|
13
|
+
* `config.ajaxSetting`, which the controlled `sortInfo` prop reads and the next
|
|
14
|
+
* request carries. So this sheet is a second caller of the same function, not a
|
|
15
|
+
* second sort implementation, and there is no new state and no extra fetch.
|
|
16
|
+
*
|
|
17
|
+
* SORT SURVIVES THE COLUMN SET. `computedSortInfo` comes straight from the
|
|
18
|
+
* prop and is never validated against the mounted columns, so a grid showing
|
|
19
|
+
* one synthetic `__card` column still sorts on `firstname`.
|
|
20
|
+
*
|
|
21
|
+
* WHICH COLUMNS ARE OFFERED, and the two independent reasons one is not:
|
|
22
|
+
*
|
|
23
|
+
* - `sortable: false` in the view config — an accessor computed per row
|
|
24
|
+
* (`owner_label`), or a relation the backend cannot order by.
|
|
25
|
+
* - the server's own `columns_metadata`, the same answer the column headers
|
|
26
|
+
* ask `isColumnSortable` for. Offering a control that answers 500 is worse
|
|
27
|
+
* than not offering it.
|
|
28
|
+
*
|
|
29
|
+
* A column whose id is an ARRAY is a relation chain and has no single column
|
|
30
|
+
* name to order by, so it is offered only where the config names one in
|
|
31
|
+
* `sort`. That is deliberately conservative: the server's allow-list takes real
|
|
32
|
+
* column names, and a guess that reaches it comes back as an error the reader
|
|
33
|
+
* cannot act on.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} props
|
|
36
|
+
* @param {object[]} props.columns the view config's columns
|
|
37
|
+
* @param {object} props.columnsMetadata the server's per-column metadata
|
|
38
|
+
* @param {string|null} props.sortBy currently sorted field
|
|
39
|
+
* @param {string|null} props.sort `'asc'` | `'desc'`
|
|
40
|
+
* @param {(value: {id: string, dir: number}) => void} props.onSort
|
|
41
|
+
* @param {() => void} props.onClose
|
|
42
|
+
*/
|
|
43
|
+
const DataGridSortSheet = ({
|
|
44
|
+
columns,
|
|
45
|
+
columnsMetadata,
|
|
46
|
+
sortBy,
|
|
47
|
+
sort,
|
|
48
|
+
onSort,
|
|
49
|
+
onClose,
|
|
50
|
+
}) => {
|
|
51
|
+
const options = (columns || [])
|
|
52
|
+
.map((column) => {
|
|
53
|
+
if (!column || column.visible === false || column.type === 'action') {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const name =
|
|
58
|
+
column.sort ??
|
|
59
|
+
(Array.isArray(column.id) ? null : column.id ?? null);
|
|
60
|
+
|
|
61
|
+
if (!name || column.sortable === false) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!isColumnSortable(name, columnsMetadata)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { name: String(name), label: column.label || String(name) };
|
|
70
|
+
})
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
// A view can declare the same field twice (one column linking to the
|
|
73
|
+
// record, another to its client); one row per field is what a reader
|
|
74
|
+
// expects to choose from.
|
|
75
|
+
.filter(
|
|
76
|
+
(option, index, all) =>
|
|
77
|
+
all.findIndex((other) => other.name === option.name) === index
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const apply = (name, dir) => {
|
|
81
|
+
onSort({ id: name, dir });
|
|
82
|
+
onClose();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<StandardModal
|
|
87
|
+
isOpen
|
|
88
|
+
onClose={onClose}
|
|
89
|
+
title="Sort"
|
|
90
|
+
variant="sheet"
|
|
91
|
+
size="small"
|
|
92
|
+
showHeader
|
|
93
|
+
>
|
|
94
|
+
<div className={styles.sortSheet}>
|
|
95
|
+
{options.length === 0 ? (
|
|
96
|
+
<p className={styles.sortSheetEmpty}>
|
|
97
|
+
Nothing on this list can be sorted.
|
|
98
|
+
</p>
|
|
99
|
+
) : (
|
|
100
|
+
options.map((option) => {
|
|
101
|
+
const active = sortBy === option.name;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
className={styles.sortSheetRow}
|
|
106
|
+
key={option.name}
|
|
107
|
+
>
|
|
108
|
+
<span className={styles.sortSheetLabel}>
|
|
109
|
+
{active ? (
|
|
110
|
+
<Check size={14} strokeWidth={2.5} />
|
|
111
|
+
) : null}
|
|
112
|
+
{option.label}
|
|
113
|
+
</span>
|
|
114
|
+
{/* Both directions are always drawn, and the
|
|
115
|
+
current one is marked. A single button that
|
|
116
|
+
toggles makes the reader press it to find
|
|
117
|
+
out which way it is going to go. */}
|
|
118
|
+
<span className={styles.sortSheetDirections}>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
aria-label={`Sort by ${option.label}, ascending`}
|
|
122
|
+
aria-pressed={active && sort === 'asc'}
|
|
123
|
+
className={
|
|
124
|
+
active && sort === 'asc'
|
|
125
|
+
? `${styles.sortSheetDirection} ${styles.sortSheetDirectionOn}`
|
|
126
|
+
: styles.sortSheetDirection
|
|
127
|
+
}
|
|
128
|
+
onClick={() => apply(option.name, 1)}
|
|
129
|
+
>
|
|
130
|
+
<ArrowUp size={16} strokeWidth={2} />
|
|
131
|
+
</button>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
aria-label={`Sort by ${option.label}, descending`}
|
|
135
|
+
aria-pressed={active && sort === 'desc'}
|
|
136
|
+
className={
|
|
137
|
+
active && sort === 'desc'
|
|
138
|
+
? `${styles.sortSheetDirection} ${styles.sortSheetDirectionOn}`
|
|
139
|
+
: styles.sortSheetDirection
|
|
140
|
+
}
|
|
141
|
+
onClick={() => apply(option.name, -1)}
|
|
142
|
+
>
|
|
143
|
+
<ArrowDown size={16} strokeWidth={2} />
|
|
144
|
+
</button>
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
})
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
</StandardModal>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export default DataGridSortSheet;
|
|
@@ -21,6 +21,10 @@ import Verify from '../auth/Verify';
|
|
|
21
21
|
import TwoFactorAuth from '../auth/TwoFactorAuth';
|
|
22
22
|
import ClientLogin from '../auth/ClientLogin';
|
|
23
23
|
import ClientOTPVerify from '../auth/ClientOTPVerify';
|
|
24
|
+
// Both client steps are route elements here, so they own the page and have to
|
|
25
|
+
// say so: the split layout is sized by a parent that, on a bare route, has no
|
|
26
|
+
// height to give. See ClientAuthScreen.
|
|
27
|
+
import ClientAuthScreen from '../auth/ClientAuthScreen';
|
|
24
28
|
import {
|
|
25
29
|
resolveAuthEndpoints,
|
|
26
30
|
resolveClientPaths,
|
|
@@ -100,6 +104,14 @@ const GenericAuth = ({
|
|
|
100
104
|
clientPortalConfig = null, // Configuration for client portal with component and URLs
|
|
101
105
|
config = {}, // Configuration object for auth features
|
|
102
106
|
density = 'default', // 'default' | 'large' — app-wide display density
|
|
107
|
+
// Whether every DataGrid in this app draws stacked card rows below 640px
|
|
108
|
+
// instead of a table that scrolls sideways. Opt-in per APPLICATION, not
|
|
109
|
+
// per library version: the mapping that decides what a card says is a
|
|
110
|
+
// heuristic over a view's columns, and three apps consume this library —
|
|
111
|
+
// a title picked badly on a screen nobody has looked at is worse than the
|
|
112
|
+
// sideways table it replaced. A single view can still opt itself in with
|
|
113
|
+
// `tableSetting.cardLayout`, and out with `cardLayout: false`.
|
|
114
|
+
cardRows = false,
|
|
103
115
|
// Every URL the auth journey talks to, merged over the 6.6.3 literals.
|
|
104
116
|
// Threaded from here into each screen so one object configures the lot.
|
|
105
117
|
// See auth/authEndpoints.js for the keys and their defaults.
|
|
@@ -154,6 +166,22 @@ const GenericAuth = ({
|
|
|
154
166
|
};
|
|
155
167
|
}, [density]);
|
|
156
168
|
|
|
169
|
+
// Same mechanism, same node, same reasoning: `useCardRowsEnabled` in
|
|
170
|
+
// utils/useDensity.js reads this attribute off <html>, so an app opts its
|
|
171
|
+
// grids in from one place and every grid — including one mounted inside a
|
|
172
|
+
// modal on a detail tab — sees it without a prop being threaded to it.
|
|
173
|
+
// Absent means absent: an app that never passes the prop writes no
|
|
174
|
+
// attribute and nothing in the DataGrid changes.
|
|
175
|
+
useInsertionEffect(() => {
|
|
176
|
+
if (typeof document === 'undefined' || !cardRows) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
document.documentElement.setAttribute('data-card-rows', 'on');
|
|
180
|
+
return () => {
|
|
181
|
+
document.documentElement.removeAttribute('data-card-rows');
|
|
182
|
+
};
|
|
183
|
+
}, [cardRows]);
|
|
184
|
+
|
|
157
185
|
const updateAuthStatus = async () => {
|
|
158
186
|
// The loading curtain covers two moments and stays down for neither
|
|
159
187
|
// more nor less: the first profile check of a page load (no usable
|
|
@@ -539,15 +567,17 @@ const ClientLoginComponent = React.memo(
|
|
|
539
567
|
paths,
|
|
540
568
|
options,
|
|
541
569
|
}) => (
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
570
|
+
<ClientAuthScreen>
|
|
571
|
+
<ClientLogin
|
|
572
|
+
loginBg={loginBg}
|
|
573
|
+
logo={logo}
|
|
574
|
+
setSystemAuth={setIsAuthenticated}
|
|
575
|
+
setUserProfile={setUserProfile}
|
|
576
|
+
urls={urls}
|
|
577
|
+
paths={paths}
|
|
578
|
+
{...(options || {})}
|
|
579
|
+
/>
|
|
580
|
+
</ClientAuthScreen>
|
|
551
581
|
)
|
|
552
582
|
);
|
|
553
583
|
|
|
@@ -561,15 +591,17 @@ const ClientOTPVerifyComponent = React.memo(
|
|
|
561
591
|
paths,
|
|
562
592
|
options,
|
|
563
593
|
}) => (
|
|
564
|
-
<
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
594
|
+
<ClientAuthScreen>
|
|
595
|
+
<ClientOTPVerify
|
|
596
|
+
loginBg={loginBg}
|
|
597
|
+
logo={logo}
|
|
598
|
+
setSystemAuth={setIsAuthenticated}
|
|
599
|
+
setUserProfile={setUserProfile}
|
|
600
|
+
urls={urls}
|
|
601
|
+
paths={paths}
|
|
602
|
+
{...(options || {})}
|
|
603
|
+
/>
|
|
604
|
+
</ClientAuthScreen>
|
|
573
605
|
)
|
|
574
606
|
);
|
|
575
607
|
|
|
@@ -1297,7 +1297,26 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
|
|
|
1297
1297
|
};
|
|
1298
1298
|
|
|
1299
1299
|
const renderWidget = (widget) => {
|
|
1300
|
-
const
|
|
1300
|
+
const rawWidgetData = data[widget.id];
|
|
1301
|
+
/*
|
|
1302
|
+
* `??` FOR A COUNTER, AND THAT IS THE WHOLE OF A LONG-STANDING BUG.
|
|
1303
|
+
*
|
|
1304
|
+
* A counter's value is a scalar and ZERO IS A PERFECTLY GOOD ONE —
|
|
1305
|
+
* no unassigned tickets, no support hours left. `|| []` swapped that
|
|
1306
|
+
* zero for an empty array, and the `typeof` gate on the value below
|
|
1307
|
+
* (number or string, or else ` `) then rejected the array and
|
|
1308
|
+
* drew a blank. So the two cards reporting the best possible news
|
|
1309
|
+
* were the two that rendered as an empty box with a button under it,
|
|
1310
|
+
* at every width, while the card reporting one open ticket worked.
|
|
1311
|
+
*
|
|
1312
|
+
* Everything else keeps the `|| []` it has always had: a chart handed
|
|
1313
|
+
* a falsy scalar where it expects rows throws inside the chart
|
|
1314
|
+
* library, and `[]` is the shape those widgets already fall back to.
|
|
1315
|
+
*/
|
|
1316
|
+
const widgetData =
|
|
1317
|
+
widget.type === 'counter'
|
|
1318
|
+
? rawWidgetData ?? []
|
|
1319
|
+
: rawWidgetData || [];
|
|
1301
1320
|
|
|
1302
1321
|
// More detailed check for valid data
|
|
1303
1322
|
const hasData =
|