@khester/dynamics-cell-renderers 1.1.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/dist/editable/EditableDateCell.d.ts +29 -0
- package/dist/editable/EditableDateCell.d.ts.map +1 -0
- package/dist/editable/EditableLookupCell.d.ts +54 -0
- package/dist/editable/EditableLookupCell.d.ts.map +1 -0
- package/dist/editable/EditableNumberCell.d.ts +30 -0
- package/dist/editable/EditableNumberCell.d.ts.map +1 -0
- package/dist/editable/EditableOptionSetCell.d.ts +28 -0
- package/dist/editable/EditableOptionSetCell.d.ts.map +1 -0
- package/dist/editable/EditableRatingCell.d.ts +33 -0
- package/dist/editable/EditableRatingCell.d.ts.map +1 -0
- package/dist/editable/EditableTextCell.d.ts +28 -0
- package/dist/editable/EditableTextCell.d.ts.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +2107 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +2156 -0
- package/dist/index.js.map +1 -0
- package/dist/renderers/ColoredCell.d.ts +8 -0
- package/dist/renderers/ColoredCell.d.ts.map +1 -0
- package/dist/renderers/CompositeCell.d.ts +13 -0
- package/dist/renderers/CompositeCell.d.ts.map +1 -0
- package/dist/renderers/CurrencyCell.d.ts +8 -0
- package/dist/renderers/CurrencyCell.d.ts.map +1 -0
- package/dist/renderers/DateCell.d.ts +8 -0
- package/dist/renderers/DateCell.d.ts.map +1 -0
- package/dist/renderers/IconCell.d.ts +8 -0
- package/dist/renderers/IconCell.d.ts.map +1 -0
- package/dist/renderers/LinkCell.d.ts +8 -0
- package/dist/renderers/LinkCell.d.ts.map +1 -0
- package/dist/renderers/LookupCell.d.ts +20 -0
- package/dist/renderers/LookupCell.d.ts.map +1 -0
- package/dist/renderers/NumberCell.d.ts +8 -0
- package/dist/renderers/NumberCell.d.ts.map +1 -0
- package/dist/renderers/OptionSetCell.d.ts +9 -0
- package/dist/renderers/OptionSetCell.d.ts.map +1 -0
- package/dist/renderers/ProgressBarCell.d.ts +8 -0
- package/dist/renderers/ProgressBarCell.d.ts.map +1 -0
- package/dist/renderers/RatingCell.d.ts +15 -0
- package/dist/renderers/RatingCell.d.ts.map +1 -0
- package/dist/renderers/TextCell.d.ts +8 -0
- package/dist/renderers/TextCell.d.ts.map +1 -0
- package/dist/renderers/ToggleCell.d.ts +8 -0
- package/dist/renderers/ToggleCell.d.ts.map +1 -0
- package/dist/renderers/emojiRating.d.ts +22 -0
- package/dist/renderers/emojiRating.d.ts.map +1 -0
- package/dist/styles/index.d.ts +121 -0
- package/dist/styles/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +245 -0
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,2107 @@
|
|
|
1
|
+
import { mergeStyles, Text, Icon, Toggle, IconButton, TextField, DatePicker, ComboBox, Dropdown, Callout, DirectionalHint } from '@fluentui/react';
|
|
2
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
|
+
import { useCallback, useState, useRef, useEffect, useMemo } from 'react';
|
|
4
|
+
import { formatCurrency, formatDateTime, formatDate, formatNumber, getContrastColor, normalizeHexColor } from '@khester/dynamics-utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dynamics 365 Grid color tokens.
|
|
8
|
+
* Inlined for standalone package use.
|
|
9
|
+
*/
|
|
10
|
+
const dynamicsGridColors = {
|
|
11
|
+
headerBackground: '#f8f8f8',
|
|
12
|
+
headerBorder: '#e1e1e1',
|
|
13
|
+
rowSelected: '#cce4f6',
|
|
14
|
+
rowSelectedBorder: '#0078d4',
|
|
15
|
+
rowSelectedHover: '#b3d7f2',
|
|
16
|
+
rowHover: '#f3f2f1'};
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Cell-Level Styles
|
|
19
|
+
// ============================================================================
|
|
20
|
+
/**
|
|
21
|
+
* Cell container - ensures proper vertical alignment.
|
|
22
|
+
*/
|
|
23
|
+
const cellClass = mergeStyles({
|
|
24
|
+
display: 'flex',
|
|
25
|
+
alignItems: 'center',
|
|
26
|
+
height: '100%',
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Link cell style - clickable text with hover underline.
|
|
30
|
+
*/
|
|
31
|
+
const linkCellClass = mergeStyles({
|
|
32
|
+
fontWeight: 600,
|
|
33
|
+
color: '#0078d4',
|
|
34
|
+
cursor: 'pointer',
|
|
35
|
+
'&:hover': {
|
|
36
|
+
textDecoration: 'underline',
|
|
37
|
+
},
|
|
38
|
+
'&:focus': {
|
|
39
|
+
outline: '2px solid #0078d4',
|
|
40
|
+
outlineOffset: 2,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* Colored cell badge style.
|
|
45
|
+
*/
|
|
46
|
+
const coloredBadgeClass = mergeStyles({
|
|
47
|
+
padding: '2px 8px',
|
|
48
|
+
borderRadius: 4,
|
|
49
|
+
fontSize: 12,
|
|
50
|
+
fontWeight: 600,
|
|
51
|
+
display: 'inline-block',
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* Currency cell style - right-aligned numbers.
|
|
55
|
+
*/
|
|
56
|
+
const currencyCellClass = mergeStyles({
|
|
57
|
+
textAlign: 'right',
|
|
58
|
+
fontFamily: 'Consolas, "Courier New", monospace',
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Progress bar container.
|
|
62
|
+
*/
|
|
63
|
+
const progressBarContainerClass = mergeStyles({
|
|
64
|
+
display: 'flex',
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
gap: 8,
|
|
67
|
+
width: '100%',
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Progress bar background.
|
|
71
|
+
*/
|
|
72
|
+
const progressBarBackgroundClass = mergeStyles({
|
|
73
|
+
flex: 1,
|
|
74
|
+
height: 8,
|
|
75
|
+
backgroundColor: '#edebe9',
|
|
76
|
+
borderRadius: 4,
|
|
77
|
+
overflow: 'hidden',
|
|
78
|
+
});
|
|
79
|
+
/**
|
|
80
|
+
* Progress bar fill (requires dynamic width via style prop).
|
|
81
|
+
*/
|
|
82
|
+
const progressBarFillClass = mergeStyles({
|
|
83
|
+
height: '100%',
|
|
84
|
+
backgroundColor: '#0078d4',
|
|
85
|
+
borderRadius: 4,
|
|
86
|
+
transition: 'width 0.3s ease',
|
|
87
|
+
});
|
|
88
|
+
/**
|
|
89
|
+
* Progress bar label.
|
|
90
|
+
*/
|
|
91
|
+
const progressBarLabelClass = mergeStyles({
|
|
92
|
+
fontSize: 12,
|
|
93
|
+
color: '#605e5c',
|
|
94
|
+
minWidth: 40,
|
|
95
|
+
textAlign: 'right',
|
|
96
|
+
});
|
|
97
|
+
/**
|
|
98
|
+
* Icon button cell style.
|
|
99
|
+
*/
|
|
100
|
+
const iconButtonCellClass = mergeStyles({
|
|
101
|
+
display: 'flex',
|
|
102
|
+
alignItems: 'center',
|
|
103
|
+
justifyContent: 'center',
|
|
104
|
+
});
|
|
105
|
+
/**
|
|
106
|
+
* Rating cell container - row of star glyphs.
|
|
107
|
+
*/
|
|
108
|
+
const ratingCellClass = mergeStyles({
|
|
109
|
+
display: 'flex',
|
|
110
|
+
alignItems: 'center',
|
|
111
|
+
gap: 2,
|
|
112
|
+
height: '100%',
|
|
113
|
+
});
|
|
114
|
+
/**
|
|
115
|
+
* Single rating star glyph.
|
|
116
|
+
*/
|
|
117
|
+
const ratingStarClass = mergeStyles({
|
|
118
|
+
fontSize: 16,
|
|
119
|
+
lineHeight: 1,
|
|
120
|
+
userSelect: 'none',
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* Composite cell container - lays out ordered slots.
|
|
124
|
+
*/
|
|
125
|
+
const compositeCellClass = mergeStyles({
|
|
126
|
+
display: 'flex',
|
|
127
|
+
height: '100%',
|
|
128
|
+
});
|
|
129
|
+
/**
|
|
130
|
+
* Editable cell container - base wrapper for inline edit controls.
|
|
131
|
+
*/
|
|
132
|
+
const editableCellClass = mergeStyles({
|
|
133
|
+
display: 'flex',
|
|
134
|
+
alignItems: 'center',
|
|
135
|
+
height: '100%',
|
|
136
|
+
width: '100%',
|
|
137
|
+
position: 'relative',
|
|
138
|
+
});
|
|
139
|
+
/**
|
|
140
|
+
* Editable cell input wrapper - provides the dirty/error border indicators.
|
|
141
|
+
*/
|
|
142
|
+
const editableCellInputWrapperClass = mergeStyles({
|
|
143
|
+
display: 'flex',
|
|
144
|
+
alignItems: 'center',
|
|
145
|
+
width: '100%',
|
|
146
|
+
position: 'relative',
|
|
147
|
+
});
|
|
148
|
+
/**
|
|
149
|
+
* Dirty cell indicator - blue left border on edited cells.
|
|
150
|
+
* Apply as additional class when cell is dirty.
|
|
151
|
+
*/
|
|
152
|
+
const dirtyCellIndicatorClass = mergeStyles({
|
|
153
|
+
'&::before': {
|
|
154
|
+
content: '""',
|
|
155
|
+
position: 'absolute',
|
|
156
|
+
left: -8,
|
|
157
|
+
top: 0,
|
|
158
|
+
bottom: 0,
|
|
159
|
+
width: 3,
|
|
160
|
+
backgroundColor: '#0078d4',
|
|
161
|
+
borderRadius: '1px',
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
/**
|
|
165
|
+
* Error cell indicator - red left border on cells with validation errors.
|
|
166
|
+
* Apply as additional class when cell has error.
|
|
167
|
+
*/
|
|
168
|
+
const errorCellIndicatorClass = mergeStyles({
|
|
169
|
+
'&::before': {
|
|
170
|
+
content: '""',
|
|
171
|
+
position: 'absolute',
|
|
172
|
+
left: -8,
|
|
173
|
+
top: 0,
|
|
174
|
+
bottom: 0,
|
|
175
|
+
width: 3,
|
|
176
|
+
backgroundColor: '#d13438',
|
|
177
|
+
borderRadius: '1px',
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
/**
|
|
181
|
+
* Error message text below field.
|
|
182
|
+
*/
|
|
183
|
+
const cellErrorTextClass = mergeStyles({
|
|
184
|
+
color: '#d13438',
|
|
185
|
+
fontSize: 11,
|
|
186
|
+
position: 'absolute',
|
|
187
|
+
bottom: -14,
|
|
188
|
+
left: 0,
|
|
189
|
+
whiteSpace: 'nowrap',
|
|
190
|
+
overflow: 'hidden',
|
|
191
|
+
textOverflow: 'ellipsis',
|
|
192
|
+
maxWidth: '100%',
|
|
193
|
+
});
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// Lookup Cell Styles
|
|
196
|
+
// ============================================================================
|
|
197
|
+
/**
|
|
198
|
+
* Lookup cell container - wrapper for the search input and dropdown.
|
|
199
|
+
*/
|
|
200
|
+
const lookupCellContainerClass = mergeStyles({
|
|
201
|
+
position: 'relative',
|
|
202
|
+
width: '100%',
|
|
203
|
+
display: 'flex',
|
|
204
|
+
alignItems: 'center',
|
|
205
|
+
});
|
|
206
|
+
/**
|
|
207
|
+
* Lookup dropdown container - positioned below the input.
|
|
208
|
+
*/
|
|
209
|
+
const lookupDropdownClass = mergeStyles({
|
|
210
|
+
position: 'absolute',
|
|
211
|
+
top: '100%',
|
|
212
|
+
left: -8,
|
|
213
|
+
right: -8,
|
|
214
|
+
zIndex: 1000,
|
|
215
|
+
backgroundColor: 'white',
|
|
216
|
+
border: '1px solid #edebe9',
|
|
217
|
+
boxShadow: '0 3px 6px rgba(0, 0, 0, 0.16)',
|
|
218
|
+
maxHeight: 200,
|
|
219
|
+
overflowY: 'auto',
|
|
220
|
+
borderRadius: 2,
|
|
221
|
+
marginTop: 2,
|
|
222
|
+
});
|
|
223
|
+
/**
|
|
224
|
+
* Lookup dropdown item - each selectable option.
|
|
225
|
+
*/
|
|
226
|
+
const lookupDropdownItemClass = mergeStyles({
|
|
227
|
+
padding: '8px 12px',
|
|
228
|
+
cursor: 'pointer',
|
|
229
|
+
fontSize: 13,
|
|
230
|
+
'&:hover': {
|
|
231
|
+
backgroundColor: '#f3f2f1',
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
/**
|
|
235
|
+
* Lookup dropdown item - active/selected state.
|
|
236
|
+
*/
|
|
237
|
+
const lookupDropdownItemActiveClass = mergeStyles({
|
|
238
|
+
backgroundColor: '#f3f2f1',
|
|
239
|
+
});
|
|
240
|
+
/**
|
|
241
|
+
* Lookup dropdown item - none option (clear selection).
|
|
242
|
+
*/
|
|
243
|
+
const lookupDropdownNoneOptionClass = mergeStyles({
|
|
244
|
+
padding: '8px 12px',
|
|
245
|
+
cursor: 'pointer',
|
|
246
|
+
fontSize: 13,
|
|
247
|
+
fontStyle: 'italic',
|
|
248
|
+
color: '#605e5c',
|
|
249
|
+
borderBottom: '1px solid #edebe9',
|
|
250
|
+
'&:hover': {
|
|
251
|
+
backgroundColor: '#f3f2f1',
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
/**
|
|
255
|
+
* Lookup loading indicator.
|
|
256
|
+
*/
|
|
257
|
+
const lookupLoadingClass = mergeStyles({
|
|
258
|
+
padding: '8px 12px',
|
|
259
|
+
fontSize: 13,
|
|
260
|
+
color: '#605e5c',
|
|
261
|
+
fontStyle: 'italic',
|
|
262
|
+
});
|
|
263
|
+
/**
|
|
264
|
+
* Lookup no results message.
|
|
265
|
+
*/
|
|
266
|
+
const lookupNoResultsClass = mergeStyles({
|
|
267
|
+
padding: '8px 12px',
|
|
268
|
+
fontSize: 13,
|
|
269
|
+
color: '#605e5c',
|
|
270
|
+
fontStyle: 'italic',
|
|
271
|
+
});
|
|
272
|
+
/**
|
|
273
|
+
* Lookup clear button - positioned inside the input.
|
|
274
|
+
*/
|
|
275
|
+
const lookupClearButtonClass = mergeStyles({
|
|
276
|
+
position: 'absolute',
|
|
277
|
+
right: 4,
|
|
278
|
+
top: '50%',
|
|
279
|
+
transform: 'translateY(-50%)',
|
|
280
|
+
width: 20,
|
|
281
|
+
height: 20,
|
|
282
|
+
minWidth: 20,
|
|
283
|
+
padding: 0,
|
|
284
|
+
border: 'none',
|
|
285
|
+
backgroundColor: 'transparent',
|
|
286
|
+
cursor: 'pointer',
|
|
287
|
+
display: 'flex',
|
|
288
|
+
alignItems: 'center',
|
|
289
|
+
justifyContent: 'center',
|
|
290
|
+
color: '#605e5c',
|
|
291
|
+
'&:hover': {
|
|
292
|
+
color: '#323130',
|
|
293
|
+
backgroundColor: '#f3f2f1',
|
|
294
|
+
borderRadius: 2,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
/**
|
|
298
|
+
* Lookup read mode link - displays lookup value as clickable link.
|
|
299
|
+
*/
|
|
300
|
+
const lookupReadModeLinkClass = mergeStyles({
|
|
301
|
+
color: '#0078d4',
|
|
302
|
+
cursor: 'pointer',
|
|
303
|
+
textDecoration: 'none',
|
|
304
|
+
overflow: 'hidden',
|
|
305
|
+
textOverflow: 'ellipsis',
|
|
306
|
+
whiteSpace: 'nowrap',
|
|
307
|
+
'&:hover': {
|
|
308
|
+
textDecoration: 'underline',
|
|
309
|
+
},
|
|
310
|
+
'&:focus': {
|
|
311
|
+
outline: '2px solid #0078d4',
|
|
312
|
+
outlineOffset: 2,
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
// ============================================================================
|
|
316
|
+
// Grid-Level Styles (for consumers building full grids)
|
|
317
|
+
// ============================================================================
|
|
318
|
+
/**
|
|
319
|
+
* Get DetailsList styles for consistent Dynamics CE look.
|
|
320
|
+
*/
|
|
321
|
+
function getDetailsListStyles() {
|
|
322
|
+
return {
|
|
323
|
+
root: {},
|
|
324
|
+
headerWrapper: {
|
|
325
|
+
'.ms-DetailsHeader': {
|
|
326
|
+
backgroundColor: dynamicsGridColors.headerBackground,
|
|
327
|
+
borderBottom: `1px solid ${dynamicsGridColors.headerBorder}`,
|
|
328
|
+
paddingTop: 0,
|
|
329
|
+
height: 42,
|
|
330
|
+
},
|
|
331
|
+
'.ms-DetailsHeader-cell': {
|
|
332
|
+
fontSize: 13,
|
|
333
|
+
fontWeight: 600,
|
|
334
|
+
color: '#323130',
|
|
335
|
+
},
|
|
336
|
+
'.ms-DetailsHeader-cellTitle': {
|
|
337
|
+
fontSize: 13,
|
|
338
|
+
fontWeight: 600,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Get row styles for consistent hover behavior.
|
|
345
|
+
*/
|
|
346
|
+
function getRowStyles() {
|
|
347
|
+
return {
|
|
348
|
+
root: {
|
|
349
|
+
cursor: 'pointer',
|
|
350
|
+
'&:hover': {
|
|
351
|
+
backgroundColor: '#f3f2f1',
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Get row styles with selection highlighting (Dynamics 365 native look).
|
|
358
|
+
* @param isSelected - Whether the row is currently selected
|
|
359
|
+
*/
|
|
360
|
+
function getRowStylesWithSelection(isSelected) {
|
|
361
|
+
return {
|
|
362
|
+
root: {
|
|
363
|
+
cursor: 'pointer',
|
|
364
|
+
backgroundColor: isSelected ? dynamicsGridColors.rowSelected : undefined,
|
|
365
|
+
borderLeft: isSelected ? `3px solid ${dynamicsGridColors.rowSelectedBorder}` : '3px solid transparent',
|
|
366
|
+
'&:hover': {
|
|
367
|
+
backgroundColor: isSelected
|
|
368
|
+
? dynamicsGridColors.rowSelectedHover
|
|
369
|
+
: dynamicsGridColors.rowHover,
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Basic text cell renderer.
|
|
377
|
+
* Displays text with optional truncation and tooltip.
|
|
378
|
+
*/
|
|
379
|
+
function TextCell({ value, column, item }) {
|
|
380
|
+
const displayValue = column.formatValue
|
|
381
|
+
? column.formatValue(value, item)
|
|
382
|
+
: value;
|
|
383
|
+
const textValue = displayValue !== null && displayValue !== undefined
|
|
384
|
+
? String(displayValue)
|
|
385
|
+
: '-';
|
|
386
|
+
return (jsx("div", { className: cellClass, children: jsx(Text, { title: textValue !== '-' ? textValue : undefined, styles: {
|
|
387
|
+
root: {
|
|
388
|
+
overflow: 'hidden',
|
|
389
|
+
textOverflow: 'ellipsis',
|
|
390
|
+
whiteSpace: 'nowrap',
|
|
391
|
+
},
|
|
392
|
+
}, children: textValue }) }));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Link cell renderer.
|
|
397
|
+
* Displays a clickable link with hover underline.
|
|
398
|
+
*/
|
|
399
|
+
function LinkCell({ item, value, column }) {
|
|
400
|
+
const displayValue = column.formatValue
|
|
401
|
+
? column.formatValue(value, item)
|
|
402
|
+
: value;
|
|
403
|
+
const textValue = displayValue !== null && displayValue !== undefined
|
|
404
|
+
? String(displayValue)
|
|
405
|
+
: '(No Name)';
|
|
406
|
+
const handleClick = useCallback(() => {
|
|
407
|
+
if (column.onLinkClick) {
|
|
408
|
+
column.onLinkClick(item);
|
|
409
|
+
}
|
|
410
|
+
}, [column, item]);
|
|
411
|
+
const handleKeyDown = useCallback((event) => {
|
|
412
|
+
if ((event.key === 'Enter' || event.key === ' ') && column.onLinkClick) {
|
|
413
|
+
event.preventDefault();
|
|
414
|
+
column.onLinkClick(item);
|
|
415
|
+
}
|
|
416
|
+
}, [column, item]);
|
|
417
|
+
return (jsx("div", { className: cellClass, children: jsx("span", { className: linkCellClass, onClick: handleClick, onKeyDown: handleKeyDown, role: "button", tabIndex: 0, title: textValue, children: textValue }) }));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Currency cell renderer.
|
|
422
|
+
* Displays formatted currency values with locale support.
|
|
423
|
+
*/
|
|
424
|
+
function CurrencyCell({ value, column, item }) {
|
|
425
|
+
const numericValue = typeof value === 'number' ? value : null;
|
|
426
|
+
const displayValue = column.formatValue
|
|
427
|
+
? column.formatValue(value, item)
|
|
428
|
+
: numericValue !== null
|
|
429
|
+
? formatCurrency(numericValue, column.currencyCode || 'USD', column.locale || 'en-US')
|
|
430
|
+
: '-';
|
|
431
|
+
return (jsx("div", { className: cellClass, children: jsx(Text, { className: currencyCellClass, title: displayValue, children: displayValue }) }));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Date cell renderer.
|
|
436
|
+
* Displays formatted date or datetime values.
|
|
437
|
+
*/
|
|
438
|
+
function DateCell({ value, column, item }) {
|
|
439
|
+
const stringValue = value !== null && value !== undefined ? String(value) : null;
|
|
440
|
+
const locale = column.locale || 'en-US';
|
|
441
|
+
let displayValue;
|
|
442
|
+
if (column.formatValue) {
|
|
443
|
+
displayValue = column.formatValue(value, item);
|
|
444
|
+
}
|
|
445
|
+
else if (stringValue) {
|
|
446
|
+
displayValue = column.fieldType === 'datetime'
|
|
447
|
+
? formatDateTime(stringValue, locale)
|
|
448
|
+
: formatDate(stringValue, locale);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
displayValue = '-';
|
|
452
|
+
}
|
|
453
|
+
return (jsx("div", { className: cellClass, children: jsx(Text, { title: displayValue !== '-' ? displayValue : undefined, children: displayValue }) }));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Number cell renderer.
|
|
458
|
+
* Displays formatted numeric values with locale support.
|
|
459
|
+
*/
|
|
460
|
+
function NumberCell({ value, column, item }) {
|
|
461
|
+
const numericValue = typeof value === 'number' ? value : null;
|
|
462
|
+
const locale = column.locale || 'en-US';
|
|
463
|
+
const displayValue = column.formatValue
|
|
464
|
+
? column.formatValue(value, item)
|
|
465
|
+
: numericValue !== null
|
|
466
|
+
? formatNumber(numericValue, locale)
|
|
467
|
+
: '-';
|
|
468
|
+
return (jsx("div", { className: cellClass, children: jsx(Text, { title: displayValue !== '-' ? displayValue : undefined, styles: {
|
|
469
|
+
root: {
|
|
470
|
+
textAlign: 'right',
|
|
471
|
+
width: '100%',
|
|
472
|
+
},
|
|
473
|
+
}, children: displayValue }) }));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Default colors for option set values.
|
|
478
|
+
*/
|
|
479
|
+
const DEFAULT_OPTIONSET_COLORS = {
|
|
480
|
+
0: '#107c10', // Active/Yes (green)
|
|
481
|
+
1: '#d13438', // Inactive/No (red)
|
|
482
|
+
2: '#0078d4', // Open (blue)
|
|
483
|
+
3: '#ffb900', // Pending (yellow)
|
|
484
|
+
4: '#605e5c', // Closed (gray)
|
|
485
|
+
};
|
|
486
|
+
/**
|
|
487
|
+
* Resolve one optionset value → a badge (label + background + optional icon).
|
|
488
|
+
* Returns null for empty/unlabeled values. Color precedence: per-option `color` >
|
|
489
|
+
* `getBackgroundColor` > `colorMap` > numeric default > gray. Label precedence:
|
|
490
|
+
* per-option `label` > `formatValue` > raw string.
|
|
491
|
+
*/
|
|
492
|
+
function resolveBadge(v, column, item) {
|
|
493
|
+
if (v === null || v === undefined || v === '')
|
|
494
|
+
return null;
|
|
495
|
+
const option = column.optionSetOptions?.find((o) => String(o.value) === String(v));
|
|
496
|
+
const label = option?.label ?? (column.formatValue ? column.formatValue(v, item) : String(v));
|
|
497
|
+
if (!label || label === '-')
|
|
498
|
+
return null;
|
|
499
|
+
let bg = option?.color;
|
|
500
|
+
if (!bg && column.getBackgroundColor)
|
|
501
|
+
bg = column.getBackgroundColor(v);
|
|
502
|
+
if (!bg && column.colorMap) {
|
|
503
|
+
const key = typeof v === 'number' ? v : String(v);
|
|
504
|
+
bg = column.colorMap[key];
|
|
505
|
+
}
|
|
506
|
+
if (!bg && typeof v === 'number' && v in DEFAULT_OPTIONSET_COLORS)
|
|
507
|
+
bg = DEFAULT_OPTIONSET_COLORS[v];
|
|
508
|
+
if (!bg)
|
|
509
|
+
bg = '#c8c6c4';
|
|
510
|
+
return { label, bg: normalizeHexColor(bg), iconName: option?.iconName };
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* OptionSet cell renderer. Displays a colored badge per value — one for a single
|
|
514
|
+
* value, several for a multi-select array. Per-option `iconName` renders a Fluent
|
|
515
|
+
* `<Icon>` (requires `initializeIcons()` on the host).
|
|
516
|
+
*/
|
|
517
|
+
function OptionSetCell({ item, value, column }) {
|
|
518
|
+
const multi = Array.isArray(value);
|
|
519
|
+
const values = multi ? value : [value];
|
|
520
|
+
const badges = values.map((v) => resolveBadge(v, column, item)).filter((b) => b !== null);
|
|
521
|
+
if (badges.length === 0) {
|
|
522
|
+
return jsx("div", { className: cellClass, children: "-" });
|
|
523
|
+
}
|
|
524
|
+
return (jsx("div", { className: cellClass, style: multi ? { display: 'flex', gap: 4, flexWrap: 'wrap' } : undefined, children: badges.map((b, i) => (jsxs("span", { className: coloredBadgeClass, style: {
|
|
525
|
+
backgroundColor: b.bg,
|
|
526
|
+
color: getContrastColor(b.bg),
|
|
527
|
+
...(b.iconName ? { display: 'inline-flex', alignItems: 'center', gap: 4 } : {}),
|
|
528
|
+
}, children: [b.iconName && jsx(Icon, { iconName: b.iconName, style: { fontSize: 12 }, "aria-hidden": true }), b.label] }, `${b.label}-${i}`))) }));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Toggle cell renderer.
|
|
533
|
+
* Displays a toggle switch for boolean values.
|
|
534
|
+
*/
|
|
535
|
+
function ToggleCell({ item, value, column, itemKey }) {
|
|
536
|
+
const boolValue = Boolean(value);
|
|
537
|
+
const handleChange = useCallback((_event, checked) => {
|
|
538
|
+
if (column.onToggleChange && column.fieldName) {
|
|
539
|
+
column.onToggleChange(itemKey, column.fieldName, checked ?? false);
|
|
540
|
+
}
|
|
541
|
+
}, [column, itemKey]);
|
|
542
|
+
// Stop propagation to prevent row click when clicking toggle
|
|
543
|
+
const handleClick = useCallback((event) => {
|
|
544
|
+
event.stopPropagation();
|
|
545
|
+
}, []);
|
|
546
|
+
return (jsx("div", { className: cellClass, onClick: handleClick, children: jsx(Toggle, { checked: boolValue, onText: column.toggleOnText || 'Yes', offText: column.toggleOffText || 'No', onChange: handleChange, disabled: !column.editable, styles: {
|
|
547
|
+
root: { margin: 0 },
|
|
548
|
+
} }) }));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Icon cell renderer.
|
|
553
|
+
* Displays an icon button with click handler.
|
|
554
|
+
*/
|
|
555
|
+
function IconCell({ item, column }) {
|
|
556
|
+
const iconProps = {
|
|
557
|
+
iconName: column.iconName || 'More',
|
|
558
|
+
};
|
|
559
|
+
const handleClick = useCallback((event) => {
|
|
560
|
+
event.stopPropagation(); // Prevent row click
|
|
561
|
+
if (column.onIconClick) {
|
|
562
|
+
column.onIconClick(item);
|
|
563
|
+
}
|
|
564
|
+
}, [column, item]);
|
|
565
|
+
return (jsx("div", { className: iconButtonCellClass, children: jsx(IconButton, { iconProps: iconProps, title: column.iconTitle || column.name, ariaLabel: column.iconTitle || column.name, onClick: handleClick, styles: {
|
|
566
|
+
root: {
|
|
567
|
+
width: 32,
|
|
568
|
+
height: 32,
|
|
569
|
+
},
|
|
570
|
+
rootHovered: {
|
|
571
|
+
backgroundColor: '#f3f2f1',
|
|
572
|
+
},
|
|
573
|
+
} }) }));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Progress bar cell renderer.
|
|
578
|
+
* Displays a horizontal progress bar with optional label.
|
|
579
|
+
*/
|
|
580
|
+
function ProgressBarCell({ value, column }) {
|
|
581
|
+
const numericValue = typeof value === 'number' ? value : 0;
|
|
582
|
+
const maxValue = column.progressMax || 100;
|
|
583
|
+
const percentage = Math.min(100, Math.max(0, (numericValue / maxValue) * 100));
|
|
584
|
+
const progressColor = column.progressColor || '#0078d4';
|
|
585
|
+
const showLabel = column.showProgressLabel !== false;
|
|
586
|
+
return (jsxs("div", { className: progressBarContainerClass, children: [jsx("div", { className: progressBarBackgroundClass, children: jsx("div", { className: progressBarFillClass, style: {
|
|
587
|
+
width: `${percentage}%`,
|
|
588
|
+
backgroundColor: progressColor,
|
|
589
|
+
} }) }), showLabel && (jsxs("span", { className: progressBarLabelClass, children: [Math.round(percentage), "%"] }))] }));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Colored cell renderer.
|
|
594
|
+
* Displays text with a colored background based on value.
|
|
595
|
+
*/
|
|
596
|
+
function ColoredCell({ item, value, column }) {
|
|
597
|
+
// Get display text
|
|
598
|
+
const displayValue = column.formatValue
|
|
599
|
+
? column.formatValue(value, item)
|
|
600
|
+
: value !== null && value !== undefined
|
|
601
|
+
? String(value)
|
|
602
|
+
: '-';
|
|
603
|
+
if (displayValue === '-') {
|
|
604
|
+
return jsx("div", { className: cellClass, children: "-" });
|
|
605
|
+
}
|
|
606
|
+
// Get background color
|
|
607
|
+
let backgroundColor;
|
|
608
|
+
if (column.getBackgroundColor && value !== null && value !== undefined) {
|
|
609
|
+
backgroundColor = column.getBackgroundColor(value);
|
|
610
|
+
}
|
|
611
|
+
else if (column.colorMap && value !== null && value !== undefined) {
|
|
612
|
+
const key = typeof value === 'number' ? value : String(value);
|
|
613
|
+
backgroundColor = column.colorMap[key];
|
|
614
|
+
}
|
|
615
|
+
// If no color mapping, render as plain text
|
|
616
|
+
if (!backgroundColor) {
|
|
617
|
+
return (jsx("div", { className: cellClass, children: displayValue }));
|
|
618
|
+
}
|
|
619
|
+
const normalizedBg = normalizeHexColor(backgroundColor);
|
|
620
|
+
const textColor = getContrastColor(normalizedBg);
|
|
621
|
+
return (jsx("div", { className: cellClass, children: jsx("span", { className: coloredBadgeClass, style: {
|
|
622
|
+
backgroundColor: normalizedBg,
|
|
623
|
+
color: textColor,
|
|
624
|
+
}, children: displayValue }) }));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Lookup cell renderer.
|
|
629
|
+
* Displays lookup field values with optional click handler or Dynamics navigation.
|
|
630
|
+
*
|
|
631
|
+
* Navigation is enabled when:
|
|
632
|
+
* - dynamicsBaseUrl is provided
|
|
633
|
+
* - column.lookupTargetEntity is configured
|
|
634
|
+
* - The lookup has a value (not null)
|
|
635
|
+
*/
|
|
636
|
+
function LookupCell({ item, value, column, dynamicsBaseUrl }) {
|
|
637
|
+
// For lookup fields, try to get display value from the lookup display field
|
|
638
|
+
let displayValue;
|
|
639
|
+
if (column.formatValue) {
|
|
640
|
+
displayValue = column.formatValue(value, item);
|
|
641
|
+
}
|
|
642
|
+
else if (column.lookupDisplayField && typeof item === 'object' && item !== null) {
|
|
643
|
+
const lookupValue = item[column.lookupDisplayField];
|
|
644
|
+
displayValue = lookupValue !== null && lookupValue !== undefined
|
|
645
|
+
? String(lookupValue)
|
|
646
|
+
: '-';
|
|
647
|
+
}
|
|
648
|
+
else if (typeof value === 'object' && value !== null) {
|
|
649
|
+
// Try common lookup value patterns
|
|
650
|
+
const lookupObj = value;
|
|
651
|
+
displayValue = String(lookupObj.name || lookupObj.fullname || lookupObj.title || '-');
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
displayValue = value !== null && value !== undefined ? String(value) : '-';
|
|
655
|
+
}
|
|
656
|
+
// Extract lookup ID for navigation
|
|
657
|
+
// Dynamics stores lookup IDs in different formats depending on $expand usage:
|
|
658
|
+
// 1. Without $expand: _fieldname_value (e.g., _primarycontactid_value)
|
|
659
|
+
// 2. With $expand: item.fieldname.entityid (e.g., item.primarycontactid.contactid)
|
|
660
|
+
const fieldName = column.fieldName || column.key;
|
|
661
|
+
const itemRecord = item;
|
|
662
|
+
// Try _fieldname_value format first (non-expanded)
|
|
663
|
+
const lookupIdKey = `_${fieldName}_value`;
|
|
664
|
+
let lookupId = itemRecord[lookupIdKey];
|
|
665
|
+
// If not found, try expanded object format
|
|
666
|
+
if (!lookupId && column.lookupPrimaryIdAttribute) {
|
|
667
|
+
const expandedValue = itemRecord[fieldName];
|
|
668
|
+
if (expandedValue && typeof expandedValue === 'object') {
|
|
669
|
+
const expanded = expandedValue;
|
|
670
|
+
lookupId = expanded[column.lookupPrimaryIdAttribute];
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Fallback: try common ID patterns in expanded object
|
|
674
|
+
if (!lookupId) {
|
|
675
|
+
const expandedValue = itemRecord[fieldName];
|
|
676
|
+
if (expandedValue && typeof expandedValue === 'object') {
|
|
677
|
+
const expanded = expandedValue;
|
|
678
|
+
// Try targetentityid (e.g., contactid, accountid)
|
|
679
|
+
if (column.lookupTargetEntity) {
|
|
680
|
+
lookupId = expanded[`${column.lookupTargetEntity}id`];
|
|
681
|
+
}
|
|
682
|
+
// Try generic patterns
|
|
683
|
+
if (!lookupId) {
|
|
684
|
+
lookupId = (expanded.id || expanded.Id);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// Determine if navigation is available
|
|
689
|
+
const canNavigate = !!(dynamicsBaseUrl && column.lookupTargetEntity && lookupId && displayValue !== '-');
|
|
690
|
+
// Handle navigation to Dynamics record
|
|
691
|
+
const handleNavigate = () => {
|
|
692
|
+
if (canNavigate) {
|
|
693
|
+
const url = `${dynamicsBaseUrl}/main.aspx?etn=${column.lookupTargetEntity}&id=${lookupId}&pagetype=entityrecord`;
|
|
694
|
+
window.open(url, '_blank');
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
const handleKeyDown = (event) => {
|
|
698
|
+
if ((event.key === 'Enter' || event.key === ' ')) {
|
|
699
|
+
event.preventDefault();
|
|
700
|
+
if (column.onLinkClick) {
|
|
701
|
+
column.onLinkClick(item);
|
|
702
|
+
}
|
|
703
|
+
else if (canNavigate) {
|
|
704
|
+
handleNavigate();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
// If there's a custom click handler, use it (priority over navigation)
|
|
709
|
+
if (column.onLinkClick && displayValue !== '-') {
|
|
710
|
+
const handleClick = () => {
|
|
711
|
+
if (column.onLinkClick) {
|
|
712
|
+
column.onLinkClick(item);
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
return (jsx("div", { className: cellClass, children: jsx("span", { className: linkCellClass, onClick: handleClick, onKeyDown: handleKeyDown, role: "button", tabIndex: 0, title: displayValue, children: displayValue }) }));
|
|
716
|
+
}
|
|
717
|
+
// If navigation is available, render as clickable link
|
|
718
|
+
if (canNavigate) {
|
|
719
|
+
return (jsx("div", { className: cellClass, children: jsx("span", { className: linkCellClass, onClick: handleNavigate, onKeyDown: handleKeyDown, role: "link", tabIndex: 0, title: `Open ${displayValue} in Dynamics 365`, children: displayValue }) }));
|
|
720
|
+
}
|
|
721
|
+
// Default: render as plain text
|
|
722
|
+
return (jsx("div", { className: cellClass, children: jsx(Text, { title: displayValue !== '-' ? displayValue : undefined, children: displayValue }) }));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Shared emoji-rating data + helpers for the 'emoji' rating style.
|
|
727
|
+
*
|
|
728
|
+
* Ported from the form-designer rating control (sentiment faces, 2–5 count).
|
|
729
|
+
* Used by both the read-only `RatingCell` (single selected face) and the
|
|
730
|
+
* `EditableRatingCell` (clickable face row).
|
|
731
|
+
*
|
|
732
|
+
* NOTE: the emoji style renders Fluent `<Icon>` faces, which require the host to
|
|
733
|
+
* have called `initializeIcons()`. The 'stars' style stays icon-registration-free.
|
|
734
|
+
*/
|
|
735
|
+
/** Fluent icon names + accessible labels per face count (2–5). */
|
|
736
|
+
const EMOJI_SETS = {
|
|
737
|
+
2: { icons: ['EmojiDisappointed', 'Emoji'], labels: ['Unhappy', 'Happy'] },
|
|
738
|
+
3: {
|
|
739
|
+
icons: ['EmojiDisappointed', 'EmojiNeutral', 'Emoji'],
|
|
740
|
+
labels: ['Unhappy', 'Neutral', 'Happy'],
|
|
741
|
+
},
|
|
742
|
+
4: {
|
|
743
|
+
icons: ['EmojiDisappointed', 'Sad', 'Emoji2', 'Emoji'],
|
|
744
|
+
labels: ['Very Unhappy', 'Unhappy', 'Happy', 'Very Happy'],
|
|
745
|
+
},
|
|
746
|
+
5: {
|
|
747
|
+
icons: ['EmojiDisappointed', 'Sad', 'EmojiNeutral', 'Emoji2', 'Emoji'],
|
|
748
|
+
labels: ['Very Unhappy', 'Unhappy', 'Neutral', 'Happy', 'Very Happy'],
|
|
749
|
+
},
|
|
750
|
+
};
|
|
751
|
+
/** Sentiment colors (red→green) per face count, aligned to `EMOJI_SETS`. */
|
|
752
|
+
const EMOJI_COLORS = {
|
|
753
|
+
2: ['#d13438', '#107c10'],
|
|
754
|
+
3: ['#d13438', '#c8c820', '#107c10'],
|
|
755
|
+
4: ['#d13438', '#ca5010', '#498205', '#107c10'],
|
|
756
|
+
5: ['#d13438', '#ca5010', '#c8c820', '#498205', '#107c10'],
|
|
757
|
+
};
|
|
758
|
+
/** Clamp a face count into the supported 2–5 range, falling back to ratingMax then 5. */
|
|
759
|
+
function clampFaceCount(faceCount, ratingMax) {
|
|
760
|
+
const raw = faceCount ?? ratingMax ?? 5;
|
|
761
|
+
const n = Math.floor(Number(raw));
|
|
762
|
+
return Math.max(2, Math.min(5, Number.isFinite(n) ? n : 5));
|
|
763
|
+
}
|
|
764
|
+
/** 1-based selected face index from a numeric value (0 = none/unset). */
|
|
765
|
+
function selectedFaceIndex(value, faceCount) {
|
|
766
|
+
const raw = typeof value === 'number' ? value : Number(value);
|
|
767
|
+
if (!Number.isFinite(raw) || raw <= 0)
|
|
768
|
+
return 0;
|
|
769
|
+
return Math.min(faceCount, Math.max(1, Math.round(raw)));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Rating cell renderer.
|
|
774
|
+
* Displays a star rating (e.g. 0–5) from a numeric value. With `ratingAllowHalf`
|
|
775
|
+
* (default true) each star fills to the exact fraction of the value (so 3.5 shows
|
|
776
|
+
* three and a half filled stars); set it false to floor to whole stars.
|
|
777
|
+
*
|
|
778
|
+
* NOTE: the default `ratingStyle: 'stars'` uses unicode stars colored via CSS
|
|
779
|
+
* `background-clip: text`, so no Fluent icon registration is required. The
|
|
780
|
+
* `ratingStyle: 'emoji'` (sentiment faces) variant renders Fluent `<Icon>`s and
|
|
781
|
+
* therefore REQUIRES the host to have called `initializeIcons()` (same as IconCell).
|
|
782
|
+
*/
|
|
783
|
+
function RatingCell({ value, column }) {
|
|
784
|
+
// Emoji (sentiment) style: a single selected face, optionally sentiment-colored.
|
|
785
|
+
if (column.ratingStyle === 'emoji') {
|
|
786
|
+
const faceCount = clampFaceCount(column.ratingFaceCount, column.ratingMax);
|
|
787
|
+
const set = EMOJI_SETS[faceCount];
|
|
788
|
+
const idx = selectedFaceIndex(value, faceCount); // 1-based, 0 = none
|
|
789
|
+
if (idx === 0) {
|
|
790
|
+
return (jsx("div", { className: ratingCellClass, role: "img", "aria-label": "No rating", children: "\u2013" }));
|
|
791
|
+
}
|
|
792
|
+
const useColor = column.ratingUseSentimentColor !== false; // default true for emoji
|
|
793
|
+
const color = useColor ? EMOJI_COLORS[faceCount][idx - 1] : column.ratingColor || '#605e5c';
|
|
794
|
+
return (jsx("div", { className: ratingCellClass, role: "img", "aria-label": `${set.labels[idx - 1]} (${idx} out of ${faceCount})`, children: jsx(Icon, { iconName: set.icons[idx - 1], style: { color, fontSize: 18 } }) }));
|
|
795
|
+
}
|
|
796
|
+
const max = column.ratingMax && column.ratingMax > 0 ? Math.floor(column.ratingMax) : 5;
|
|
797
|
+
const filledColor = column.ratingColor || '#ffb900';
|
|
798
|
+
const emptyColor = '#c8c6c4';
|
|
799
|
+
const allowHalf = column.ratingAllowHalf !== false;
|
|
800
|
+
const raw = typeof value === 'number' ? value : Number(value);
|
|
801
|
+
const rating = Number.isFinite(raw) ? Math.min(max, Math.max(0, raw)) : 0;
|
|
802
|
+
const stars = [];
|
|
803
|
+
for (let i = 1; i <= max; i++) {
|
|
804
|
+
// Fraction of THIS star that is filled (0..1).
|
|
805
|
+
let frac = Math.max(0, Math.min(1, rating - (i - 1)));
|
|
806
|
+
if (!allowHalf)
|
|
807
|
+
frac = frac >= 1 ? 1 : 0; // whole-star (floor) rounding
|
|
808
|
+
const pct = Math.round(frac * 100);
|
|
809
|
+
stars.push(jsx("span", { className: ratingStarClass, style: {
|
|
810
|
+
background: `linear-gradient(90deg, ${filledColor} ${pct}%, ${emptyColor} ${pct}%)`,
|
|
811
|
+
WebkitBackgroundClip: 'text',
|
|
812
|
+
backgroundClip: 'text',
|
|
813
|
+
color: 'transparent',
|
|
814
|
+
WebkitTextFillColor: 'transparent',
|
|
815
|
+
}, "aria-hidden": "true", children: "\u2605" }, i));
|
|
816
|
+
}
|
|
817
|
+
// Announce what is actually shown: the exact value (half mode) or the floored
|
|
818
|
+
// whole-star count.
|
|
819
|
+
const announced = allowHalf ? rating : Math.floor(rating);
|
|
820
|
+
return (jsx("div", { className: ratingCellClass, role: "img", "aria-label": `${announced} out of ${max}`, children: stars }));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Read a (possibly nested-free) field off the row. */
|
|
824
|
+
function getField(item, field) {
|
|
825
|
+
if (!field || item === null || typeof item !== 'object')
|
|
826
|
+
return undefined;
|
|
827
|
+
return item[field];
|
|
828
|
+
}
|
|
829
|
+
/** Coerce to a number, treating empty/null/undefined as NaN (not 0). */
|
|
830
|
+
function asNum(x) {
|
|
831
|
+
if (x === '' || x === null || x === undefined)
|
|
832
|
+
return NaN;
|
|
833
|
+
return typeof x === 'number' ? x : Number(x);
|
|
834
|
+
}
|
|
835
|
+
/** Evaluate a single conditional-style rule against the row. */
|
|
836
|
+
function conditionMatches(cond, item) {
|
|
837
|
+
const actual = getField(item, cond.field);
|
|
838
|
+
const a = asNum(actual);
|
|
839
|
+
const c = asNum(cond.value);
|
|
840
|
+
const bothNumeric = Number.isFinite(a) && Number.isFinite(c);
|
|
841
|
+
switch (cond.operator) {
|
|
842
|
+
case 'eq':
|
|
843
|
+
// Numeric equality when both sides are numbers (so 1 === '1' and
|
|
844
|
+
// 5 === 5.0), else exact string compare.
|
|
845
|
+
return bothNumeric ? a === c : String(actual) === String(cond.value);
|
|
846
|
+
case 'gt':
|
|
847
|
+
return bothNumeric && a > c;
|
|
848
|
+
case 'lt':
|
|
849
|
+
return bothNumeric && a < c;
|
|
850
|
+
case 'gte':
|
|
851
|
+
return bothNumeric && a >= c;
|
|
852
|
+
case 'lte':
|
|
853
|
+
return bothNumeric && a <= c;
|
|
854
|
+
case 'contains':
|
|
855
|
+
return String(actual ?? '')
|
|
856
|
+
.toLowerCase()
|
|
857
|
+
.includes(String(cond.value).toLowerCase());
|
|
858
|
+
case 'between': {
|
|
859
|
+
const to = asNum(cond.valueTo !== undefined ? cond.valueTo : cond.value);
|
|
860
|
+
return Number.isFinite(a) && Number.isFinite(c) && Number.isFinite(to) && a >= c && a <= to;
|
|
861
|
+
}
|
|
862
|
+
default:
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/** Resolve a slot's style: first matching conditional style, else default. */
|
|
867
|
+
function resolveSlotStyle(slot, item) {
|
|
868
|
+
if (slot.conditionalStyles) {
|
|
869
|
+
for (const cs of slot.conditionalStyles) {
|
|
870
|
+
if (conditionMatches(cs, item))
|
|
871
|
+
return cs.style;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return slot.defaultStyle || {};
|
|
875
|
+
}
|
|
876
|
+
function renderSlot(slot, item) {
|
|
877
|
+
const style = resolveSlotStyle(slot, item);
|
|
878
|
+
const bound = getField(item, slot.fieldBinding);
|
|
879
|
+
const text = bound !== undefined && bound !== null ? String(bound) : slot.staticValue ?? '';
|
|
880
|
+
switch (slot.type) {
|
|
881
|
+
case 'spacer':
|
|
882
|
+
return jsx("span", { style: { flex: 1, ...style } }, slot.id);
|
|
883
|
+
case 'icon':
|
|
884
|
+
return (jsx(Icon, { iconName: slot.staticValue || (bound ? String(bound) : 'CircleFill'), style: style }, slot.id));
|
|
885
|
+
case 'image':
|
|
886
|
+
return (jsx("img", { src: text, alt: "", style: { height: 20, width: 20, objectFit: 'cover', borderRadius: 2, ...style } }, slot.id));
|
|
887
|
+
case 'badge':
|
|
888
|
+
return (jsx("span", { style: { padding: '2px 8px', borderRadius: 4, fontSize: 12, fontWeight: 600, ...style }, children: text }, slot.id));
|
|
889
|
+
case 'link':
|
|
890
|
+
return (jsx("a", { href: "#", onClick: (e) => e.preventDefault(), style: { color: '#0078d4', cursor: 'pointer', textDecoration: 'none', ...style }, children: text }, slot.id));
|
|
891
|
+
case 'progress': {
|
|
892
|
+
const pct = Math.min(100, Math.max(0, Number(bound) || 0));
|
|
893
|
+
return (jsx("span", { style: {
|
|
894
|
+
display: 'inline-block',
|
|
895
|
+
width: 60,
|
|
896
|
+
height: 8,
|
|
897
|
+
background: '#edebe9',
|
|
898
|
+
borderRadius: 4,
|
|
899
|
+
overflow: 'hidden',
|
|
900
|
+
...style,
|
|
901
|
+
}, children: jsx("span", { style: { display: 'block', height: '100%', width: `${pct}%`, background: '#0078d4' } }) }, slot.id));
|
|
902
|
+
}
|
|
903
|
+
case 'rating': {
|
|
904
|
+
const r = Math.round(Number(bound) || 0);
|
|
905
|
+
return (jsxs("span", { style: { color: '#ffb900', ...style }, children: ['★'.repeat(Math.max(0, r)), '☆'.repeat(Math.max(0, 5 - r))] }, slot.id));
|
|
906
|
+
}
|
|
907
|
+
case 'text':
|
|
908
|
+
default:
|
|
909
|
+
return (jsx("span", { style: style, children: text }, slot.id));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Composite cell renderer.
|
|
914
|
+
* Renders an ordered list of slots (icon/text/badge/link/image/spacer/progress/
|
|
915
|
+
* rating) in one cell, each with optional conditional styling. Configured via
|
|
916
|
+
* `column.compositeSlots` / `compositeLayout` / `compositeGap`.
|
|
917
|
+
*
|
|
918
|
+
* NOTE: the `icon` slot uses Fluent UI's `Icon`, so the host app must call
|
|
919
|
+
* `initializeIcons()` once at startup or icon slots render blank.
|
|
920
|
+
*/
|
|
921
|
+
function CompositeCell({ item, column }) {
|
|
922
|
+
const slots = column.compositeSlots || [];
|
|
923
|
+
const layout = column.compositeLayout || 'horizontal';
|
|
924
|
+
const gap = column.compositeGap ?? 4;
|
|
925
|
+
return (jsx("div", { className: compositeCellClass, style: {
|
|
926
|
+
flexDirection: layout === 'vertical' ? 'column' : 'row',
|
|
927
|
+
alignItems: layout === 'vertical' ? 'flex-start' : 'center',
|
|
928
|
+
gap,
|
|
929
|
+
}, children: slots.map((slot) => renderSlot(slot, item)) }));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Styles for the text field in edit mode
|
|
933
|
+
const textFieldStyles = {
|
|
934
|
+
root: {
|
|
935
|
+
width: '100%',
|
|
936
|
+
},
|
|
937
|
+
fieldGroup: {
|
|
938
|
+
height: 28,
|
|
939
|
+
minHeight: 28,
|
|
940
|
+
border: 'none',
|
|
941
|
+
backgroundColor: 'transparent',
|
|
942
|
+
selectors: {
|
|
943
|
+
':hover': {
|
|
944
|
+
border: '1px solid #c8c6c4',
|
|
945
|
+
backgroundColor: 'white',
|
|
946
|
+
},
|
|
947
|
+
':focus-within': {
|
|
948
|
+
border: '1px solid #0078d4',
|
|
949
|
+
backgroundColor: 'white',
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
field: {
|
|
954
|
+
fontSize: 13,
|
|
955
|
+
padding: '0 8px',
|
|
956
|
+
},
|
|
957
|
+
};
|
|
958
|
+
const textFieldErrorStyles = {
|
|
959
|
+
...textFieldStyles,
|
|
960
|
+
fieldGroup: {
|
|
961
|
+
...textFieldStyles.fieldGroup,
|
|
962
|
+
border: '1px solid #d13438',
|
|
963
|
+
selectors: {
|
|
964
|
+
':hover': {
|
|
965
|
+
border: '1px solid #d13438',
|
|
966
|
+
backgroundColor: 'white',
|
|
967
|
+
},
|
|
968
|
+
':focus-within': {
|
|
969
|
+
border: '1px solid #d13438',
|
|
970
|
+
backgroundColor: 'white',
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
const readOnlyClass$2 = mergeStyles({
|
|
976
|
+
overflow: 'hidden',
|
|
977
|
+
textOverflow: 'ellipsis',
|
|
978
|
+
whiteSpace: 'nowrap',
|
|
979
|
+
fontSize: 13,
|
|
980
|
+
});
|
|
981
|
+
/**
|
|
982
|
+
* Editable text cell renderer.
|
|
983
|
+
* Displays as text in read mode, as TextField in edit mode.
|
|
984
|
+
* Shows blue indicator when dirty, red indicator when has validation error.
|
|
985
|
+
*/
|
|
986
|
+
function EditableTextCell({ item, column, value, itemKey, isEditMode, isDirty, editedValue, onValueChange, errorMessage, onValidationError, }) {
|
|
987
|
+
const fieldName = column.fieldName || column.key;
|
|
988
|
+
const currentValue = editedValue !== undefined ? editedValue : value;
|
|
989
|
+
const stringValue = currentValue !== null && currentValue !== undefined
|
|
990
|
+
? String(currentValue)
|
|
991
|
+
: '';
|
|
992
|
+
// Local state for immediate input feedback
|
|
993
|
+
const [localValue, setLocalValue] = useState(stringValue);
|
|
994
|
+
const inputRef = useRef(null);
|
|
995
|
+
// Sync local value when props change
|
|
996
|
+
useEffect(() => {
|
|
997
|
+
setLocalValue(stringValue);
|
|
998
|
+
}, [stringValue]);
|
|
999
|
+
// Handle input change
|
|
1000
|
+
const handleChange = useCallback((_, newValue) => {
|
|
1001
|
+
const val = newValue ?? '';
|
|
1002
|
+
setLocalValue(val);
|
|
1003
|
+
// Validate if column has validate function
|
|
1004
|
+
const col = column;
|
|
1005
|
+
if (col.validate && onValidationError) {
|
|
1006
|
+
const error = col.validate(val, itemKey);
|
|
1007
|
+
onValidationError(itemKey, fieldName, error);
|
|
1008
|
+
}
|
|
1009
|
+
// Notify parent of change
|
|
1010
|
+
onValueChange(itemKey, fieldName, val || null, value);
|
|
1011
|
+
}, [column, fieldName, itemKey, value, onValueChange, onValidationError]);
|
|
1012
|
+
// Handle blur - final validation
|
|
1013
|
+
const handleBlur = useCallback(() => {
|
|
1014
|
+
const col = column;
|
|
1015
|
+
if (col.validate && onValidationError) {
|
|
1016
|
+
const error = col.validate(localValue, itemKey);
|
|
1017
|
+
onValidationError(itemKey, fieldName, error);
|
|
1018
|
+
}
|
|
1019
|
+
}, [column, fieldName, itemKey, localValue, onValidationError]);
|
|
1020
|
+
// If not in edit mode, show read-only text
|
|
1021
|
+
if (!isEditMode) {
|
|
1022
|
+
const displayValue = column.formatValue
|
|
1023
|
+
? column.formatValue(value, item)
|
|
1024
|
+
: stringValue || '-';
|
|
1025
|
+
return (jsx("div", { className: editableCellClass, children: jsx("span", { className: readOnlyClass$2, title: displayValue !== '-' ? displayValue : undefined, children: displayValue }) }));
|
|
1026
|
+
}
|
|
1027
|
+
// Determine indicator class
|
|
1028
|
+
let indicatorClass = '';
|
|
1029
|
+
if (errorMessage) {
|
|
1030
|
+
indicatorClass = errorCellIndicatorClass;
|
|
1031
|
+
}
|
|
1032
|
+
else if (isDirty) {
|
|
1033
|
+
indicatorClass = dirtyCellIndicatorClass;
|
|
1034
|
+
}
|
|
1035
|
+
const wrapperClass = mergeStyles(editableCellInputWrapperClass, indicatorClass);
|
|
1036
|
+
return (jsx("div", { className: editableCellClass, children: jsxs("div", { className: wrapperClass, children: [jsx(TextField, { componentRef: inputRef, value: localValue, onChange: handleChange, onBlur: handleBlur, borderless: true, styles: errorMessage ? textFieldErrorStyles : textFieldStyles, ariaLabel: `${column.name} for ${item.key}` }), errorMessage && (jsx("span", { className: cellErrorTextClass, title: errorMessage, children: errorMessage }))] }) }));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Styles for the number input in edit mode
|
|
1040
|
+
const numberFieldStyles = {
|
|
1041
|
+
root: {
|
|
1042
|
+
width: '100%',
|
|
1043
|
+
},
|
|
1044
|
+
fieldGroup: {
|
|
1045
|
+
height: 28,
|
|
1046
|
+
minHeight: 28,
|
|
1047
|
+
border: 'none',
|
|
1048
|
+
backgroundColor: 'transparent',
|
|
1049
|
+
selectors: {
|
|
1050
|
+
':hover': {
|
|
1051
|
+
border: '1px solid #c8c6c4',
|
|
1052
|
+
backgroundColor: 'white',
|
|
1053
|
+
},
|
|
1054
|
+
':focus-within': {
|
|
1055
|
+
border: '1px solid #0078d4',
|
|
1056
|
+
backgroundColor: 'white',
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
field: {
|
|
1061
|
+
fontSize: 13,
|
|
1062
|
+
padding: '0 8px',
|
|
1063
|
+
textAlign: 'right',
|
|
1064
|
+
},
|
|
1065
|
+
};
|
|
1066
|
+
const numberFieldErrorStyles = {
|
|
1067
|
+
...numberFieldStyles,
|
|
1068
|
+
fieldGroup: {
|
|
1069
|
+
...numberFieldStyles.fieldGroup,
|
|
1070
|
+
border: '1px solid #d13438',
|
|
1071
|
+
selectors: {
|
|
1072
|
+
':hover': {
|
|
1073
|
+
border: '1px solid #d13438',
|
|
1074
|
+
backgroundColor: 'white',
|
|
1075
|
+
},
|
|
1076
|
+
':focus-within': {
|
|
1077
|
+
border: '1px solid #d13438',
|
|
1078
|
+
backgroundColor: 'white',
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1082
|
+
};
|
|
1083
|
+
const readOnlyClass$1 = mergeStyles({
|
|
1084
|
+
overflow: 'hidden',
|
|
1085
|
+
textOverflow: 'ellipsis',
|
|
1086
|
+
whiteSpace: 'nowrap',
|
|
1087
|
+
fontSize: 13,
|
|
1088
|
+
textAlign: 'right',
|
|
1089
|
+
width: '100%',
|
|
1090
|
+
});
|
|
1091
|
+
/**
|
|
1092
|
+
* Parse a string to a number, handling currency formats.
|
|
1093
|
+
*/
|
|
1094
|
+
function parseNumericValue(value) {
|
|
1095
|
+
if (!value || value.trim() === '')
|
|
1096
|
+
return null;
|
|
1097
|
+
// Remove currency symbols, thousands separators, and whitespace
|
|
1098
|
+
const cleaned = value.replace(/[$\u20ac\u00a3\u00a5,\s]/g, '');
|
|
1099
|
+
const num = parseFloat(cleaned);
|
|
1100
|
+
return isNaN(num) ? null : num;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Editable number cell renderer.
|
|
1104
|
+
* Displays formatted number/currency in read mode, as TextField in edit mode.
|
|
1105
|
+
* Shows blue indicator when dirty, red indicator when has validation error.
|
|
1106
|
+
*/
|
|
1107
|
+
function EditableNumberCell({ item, column, value, itemKey, isEditMode, isDirty, editedValue, onValueChange, errorMessage, onValidationError, isCurrency = false, }) {
|
|
1108
|
+
const fieldName = column.fieldName || column.key;
|
|
1109
|
+
const currentValue = editedValue !== undefined ? editedValue : value;
|
|
1110
|
+
const numericValue = typeof currentValue === 'number' ? currentValue : null;
|
|
1111
|
+
// For edit mode, show raw number
|
|
1112
|
+
const editDisplayValue = numericValue !== null ? String(numericValue) : '';
|
|
1113
|
+
// Local state for immediate input feedback
|
|
1114
|
+
const [localValue, setLocalValue] = useState(editDisplayValue);
|
|
1115
|
+
const inputRef = useRef(null);
|
|
1116
|
+
// Sync local value when props change
|
|
1117
|
+
useEffect(() => {
|
|
1118
|
+
setLocalValue(editDisplayValue);
|
|
1119
|
+
}, [editDisplayValue]);
|
|
1120
|
+
// Handle input change
|
|
1121
|
+
const handleChange = useCallback((_, newValue) => {
|
|
1122
|
+
const val = newValue ?? '';
|
|
1123
|
+
setLocalValue(val);
|
|
1124
|
+
// Parse to number
|
|
1125
|
+
const parsedValue = parseNumericValue(val);
|
|
1126
|
+
// Validate if column has validate function
|
|
1127
|
+
const col = column;
|
|
1128
|
+
if (col.validate && onValidationError) {
|
|
1129
|
+
const error = col.validate(parsedValue, itemKey);
|
|
1130
|
+
onValidationError(itemKey, fieldName, error);
|
|
1131
|
+
}
|
|
1132
|
+
// Notify parent of change
|
|
1133
|
+
onValueChange(itemKey, fieldName, parsedValue, value);
|
|
1134
|
+
}, [column, fieldName, itemKey, value, onValueChange, onValidationError]);
|
|
1135
|
+
// Handle blur - validate and format
|
|
1136
|
+
const handleBlur = useCallback(() => {
|
|
1137
|
+
const parsedValue = parseNumericValue(localValue);
|
|
1138
|
+
const col = column;
|
|
1139
|
+
if (col.validate && onValidationError) {
|
|
1140
|
+
const error = col.validate(parsedValue, itemKey);
|
|
1141
|
+
onValidationError(itemKey, fieldName, error);
|
|
1142
|
+
}
|
|
1143
|
+
// Update local value to show the parsed number
|
|
1144
|
+
if (parsedValue !== null) {
|
|
1145
|
+
setLocalValue(String(parsedValue));
|
|
1146
|
+
}
|
|
1147
|
+
else if (localValue.trim() === '') {
|
|
1148
|
+
setLocalValue('');
|
|
1149
|
+
}
|
|
1150
|
+
}, [column, fieldName, itemKey, localValue, onValidationError]);
|
|
1151
|
+
// If not in edit mode, show formatted read-only text
|
|
1152
|
+
if (!isEditMode) {
|
|
1153
|
+
const locale = column.locale || 'en-US';
|
|
1154
|
+
const currencyCode = column.currencyCode || 'USD';
|
|
1155
|
+
let displayValue;
|
|
1156
|
+
if (column.formatValue) {
|
|
1157
|
+
displayValue = column.formatValue(value, item);
|
|
1158
|
+
}
|
|
1159
|
+
else if (numericValue !== null) {
|
|
1160
|
+
displayValue = isCurrency || column.fieldType === 'currency'
|
|
1161
|
+
? formatCurrency(numericValue, currencyCode, locale)
|
|
1162
|
+
: formatNumber(numericValue, locale);
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
displayValue = '-';
|
|
1166
|
+
}
|
|
1167
|
+
return (jsx("div", { className: editableCellClass, children: jsx("span", { className: mergeStyles(readOnlyClass$1, isCurrency || column.fieldType === 'currency' ? currencyCellClass : ''), title: displayValue !== '-' ? displayValue : undefined, children: displayValue }) }));
|
|
1168
|
+
}
|
|
1169
|
+
// Determine indicator class
|
|
1170
|
+
let indicatorClass = '';
|
|
1171
|
+
if (errorMessage) {
|
|
1172
|
+
indicatorClass = errorCellIndicatorClass;
|
|
1173
|
+
}
|
|
1174
|
+
else if (isDirty) {
|
|
1175
|
+
indicatorClass = dirtyCellIndicatorClass;
|
|
1176
|
+
}
|
|
1177
|
+
const wrapperClass = mergeStyles(editableCellInputWrapperClass, indicatorClass);
|
|
1178
|
+
return (jsx("div", { className: editableCellClass, children: jsxs("div", { className: wrapperClass, children: [jsx(TextField, { componentRef: inputRef, value: localValue, onChange: handleChange, onBlur: handleBlur, borderless: true, styles: errorMessage ? numberFieldErrorStyles : numberFieldStyles, ariaLabel: `${column.name} for ${item.key}`, type: "text", inputMode: "decimal" }), errorMessage && (jsx("span", { className: cellErrorTextClass, title: errorMessage, children: errorMessage }))] }) }));
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Styles for the DatePicker in edit mode
|
|
1182
|
+
const datePickerStyles = {
|
|
1183
|
+
root: {
|
|
1184
|
+
width: '100%',
|
|
1185
|
+
},
|
|
1186
|
+
textField: {
|
|
1187
|
+
fieldGroup: {
|
|
1188
|
+
height: 28,
|
|
1189
|
+
minHeight: 28,
|
|
1190
|
+
border: 'none',
|
|
1191
|
+
backgroundColor: 'transparent',
|
|
1192
|
+
selectors: {
|
|
1193
|
+
':hover': {
|
|
1194
|
+
border: '1px solid #c8c6c4',
|
|
1195
|
+
backgroundColor: 'white',
|
|
1196
|
+
},
|
|
1197
|
+
':focus-within': {
|
|
1198
|
+
border: '1px solid #0078d4',
|
|
1199
|
+
backgroundColor: 'white',
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
},
|
|
1203
|
+
field: {
|
|
1204
|
+
fontSize: 13,
|
|
1205
|
+
padding: '0 8px',
|
|
1206
|
+
lineHeight: '28px',
|
|
1207
|
+
},
|
|
1208
|
+
},
|
|
1209
|
+
};
|
|
1210
|
+
const datePickerErrorStyles = {
|
|
1211
|
+
...datePickerStyles,
|
|
1212
|
+
textField: {
|
|
1213
|
+
...datePickerStyles.textField,
|
|
1214
|
+
fieldGroup: {
|
|
1215
|
+
...datePickerStyles.textField.fieldGroup,
|
|
1216
|
+
border: '1px solid #d13438',
|
|
1217
|
+
selectors: {
|
|
1218
|
+
':hover': {
|
|
1219
|
+
border: '1px solid #d13438',
|
|
1220
|
+
backgroundColor: 'white',
|
|
1221
|
+
},
|
|
1222
|
+
':focus-within': {
|
|
1223
|
+
border: '1px solid #d13438',
|
|
1224
|
+
backgroundColor: 'white',
|
|
1225
|
+
},
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
},
|
|
1229
|
+
};
|
|
1230
|
+
// Narrower DatePicker when shown alongside time picker
|
|
1231
|
+
const dateTimePickerStyles = {
|
|
1232
|
+
...datePickerStyles,
|
|
1233
|
+
root: {
|
|
1234
|
+
flex: '1 1 60%',
|
|
1235
|
+
minWidth: 0,
|
|
1236
|
+
},
|
|
1237
|
+
};
|
|
1238
|
+
const dateTimePickerErrorStyles = {
|
|
1239
|
+
...datePickerErrorStyles,
|
|
1240
|
+
root: {
|
|
1241
|
+
flex: '1 1 60%',
|
|
1242
|
+
minWidth: 0,
|
|
1243
|
+
},
|
|
1244
|
+
};
|
|
1245
|
+
// Styles for the time ComboBox -- match DatePicker's 28px height exactly
|
|
1246
|
+
const timeComboBoxStyles = {
|
|
1247
|
+
root: {
|
|
1248
|
+
flex: '1 1 40%',
|
|
1249
|
+
minWidth: 0,
|
|
1250
|
+
height: 28,
|
|
1251
|
+
},
|
|
1252
|
+
container: {
|
|
1253
|
+
height: 28,
|
|
1254
|
+
minHeight: 28,
|
|
1255
|
+
selectors: {
|
|
1256
|
+
'::after': {
|
|
1257
|
+
// Remove Fluent's default focus border (we handle it ourselves)
|
|
1258
|
+
border: 'none',
|
|
1259
|
+
},
|
|
1260
|
+
},
|
|
1261
|
+
},
|
|
1262
|
+
input: {
|
|
1263
|
+
height: 26,
|
|
1264
|
+
fontSize: 13,
|
|
1265
|
+
padding: '0 0 0 8px',
|
|
1266
|
+
boxSizing: 'border-box',
|
|
1267
|
+
},
|
|
1268
|
+
rootHovered: {
|
|
1269
|
+
border: '1px solid #c8c6c4',
|
|
1270
|
+
backgroundColor: 'white',
|
|
1271
|
+
},
|
|
1272
|
+
rootFocused: {
|
|
1273
|
+
border: '1px solid #0078d4',
|
|
1274
|
+
backgroundColor: 'white',
|
|
1275
|
+
},
|
|
1276
|
+
rootPressed: {
|
|
1277
|
+
border: '1px solid #0078d4',
|
|
1278
|
+
backgroundColor: 'white',
|
|
1279
|
+
},
|
|
1280
|
+
};
|
|
1281
|
+
const dateTimeRowClass = mergeStyles({
|
|
1282
|
+
display: 'flex',
|
|
1283
|
+
gap: 4,
|
|
1284
|
+
width: '100%',
|
|
1285
|
+
alignItems: 'flex-start',
|
|
1286
|
+
});
|
|
1287
|
+
/**
|
|
1288
|
+
* Build time options in 15-minute increments (12-hour format).
|
|
1289
|
+
*/
|
|
1290
|
+
function buildTimeOptions() {
|
|
1291
|
+
const options = [];
|
|
1292
|
+
for (let h = 0; h < 24; h++) {
|
|
1293
|
+
for (let m = 0; m < 60; m += 15) {
|
|
1294
|
+
const hour12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
1295
|
+
const ampm = h < 12 ? 'AM' : 'PM';
|
|
1296
|
+
const label = `${hour12}:${m.toString().padStart(2, '0')} ${ampm}`;
|
|
1297
|
+
const key = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
|
1298
|
+
options.push({ key, text: label });
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
return options;
|
|
1302
|
+
}
|
|
1303
|
+
const TIME_OPTIONS = buildTimeOptions();
|
|
1304
|
+
/**
|
|
1305
|
+
* Parse an ISO string or Date into a Date object. Returns null if invalid.
|
|
1306
|
+
*/
|
|
1307
|
+
function parseToDate(value) {
|
|
1308
|
+
if (!value)
|
|
1309
|
+
return null;
|
|
1310
|
+
if (value instanceof Date)
|
|
1311
|
+
return isNaN(value.getTime()) ? null : value;
|
|
1312
|
+
const d = new Date(String(value));
|
|
1313
|
+
return isNaN(d.getTime()) ? null : d;
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Format a Date for display in the DatePicker.
|
|
1317
|
+
*/
|
|
1318
|
+
function formatDateForDisplay(date) {
|
|
1319
|
+
if (!date)
|
|
1320
|
+
return '';
|
|
1321
|
+
return date.toLocaleDateString('en-US', {
|
|
1322
|
+
year: 'numeric',
|
|
1323
|
+
month: 'short',
|
|
1324
|
+
day: 'numeric',
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Get HH:MM key from a Date for the time ComboBox.
|
|
1329
|
+
* Rounds to nearest 15-minute increment.
|
|
1330
|
+
*/
|
|
1331
|
+
function getTimeKey(date) {
|
|
1332
|
+
const h = date.getHours();
|
|
1333
|
+
const m = Math.round(date.getMinutes() / 15) * 15;
|
|
1334
|
+
const adjustedH = m === 60 ? h + 1 : h;
|
|
1335
|
+
const adjustedM = m === 60 ? 0 : m;
|
|
1336
|
+
const finalH = adjustedH % 24;
|
|
1337
|
+
return `${finalH.toString().padStart(2, '0')}:${adjustedM.toString().padStart(2, '0')}`;
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Build an ISO date-only string (YYYY-MM-DD) from a Date.
|
|
1341
|
+
*/
|
|
1342
|
+
function toDateOnlyISO(date) {
|
|
1343
|
+
const y = date.getFullYear();
|
|
1344
|
+
const m = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
1345
|
+
const d = date.getDate().toString().padStart(2, '0');
|
|
1346
|
+
return `${y}-${m}-${d}`;
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Build an ISO datetime string from a Date.
|
|
1350
|
+
*/
|
|
1351
|
+
function toDateTimeISO(date) {
|
|
1352
|
+
return date.toISOString();
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Editable date/datetime cell renderer.
|
|
1356
|
+
* - Date-only fields: Fluent UI DatePicker (calendar popup)
|
|
1357
|
+
* - DateTime fields: DatePicker + time ComboBox side by side
|
|
1358
|
+
* Shows blue indicator when dirty, red indicator when has validation error.
|
|
1359
|
+
*/
|
|
1360
|
+
function EditableDateCell({ item, column, value, itemKey, isEditMode, isDirty, editedValue, onValueChange, errorMessage, onValidationError, }) {
|
|
1361
|
+
const fieldName = column.fieldName || column.key;
|
|
1362
|
+
const currentValue = editedValue !== undefined ? editedValue : value;
|
|
1363
|
+
const isDateTime = column.fieldType === 'datetime';
|
|
1364
|
+
// Parse current value to a Date
|
|
1365
|
+
const currentDate = useMemo(() => parseToDate(currentValue), [currentValue]);
|
|
1366
|
+
// Current time key for the time ComboBox
|
|
1367
|
+
const currentTimeKey = useMemo(() => (currentDate ? getTimeKey(currentDate) : '09:00'), [currentDate]);
|
|
1368
|
+
// Validate helper
|
|
1369
|
+
const runValidation = useCallback((newValue) => {
|
|
1370
|
+
const col = column;
|
|
1371
|
+
if (col.validate && onValidationError) {
|
|
1372
|
+
const error = col.validate(newValue, itemKey);
|
|
1373
|
+
onValidationError(itemKey, fieldName, error);
|
|
1374
|
+
}
|
|
1375
|
+
}, [column, fieldName, itemKey, onValidationError]);
|
|
1376
|
+
// Handle date selection from DatePicker
|
|
1377
|
+
const handleDateSelect = useCallback((date) => {
|
|
1378
|
+
if (!date) {
|
|
1379
|
+
// Cleared
|
|
1380
|
+
runValidation(null);
|
|
1381
|
+
onValueChange(itemKey, fieldName, null, value);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (isDateTime) {
|
|
1385
|
+
// Preserve existing time or default to 09:00
|
|
1386
|
+
const existingDate = parseToDate(currentValue);
|
|
1387
|
+
if (existingDate) {
|
|
1388
|
+
date.setHours(existingDate.getHours(), existingDate.getMinutes(), 0, 0);
|
|
1389
|
+
}
|
|
1390
|
+
else {
|
|
1391
|
+
date.setHours(9, 0, 0, 0);
|
|
1392
|
+
}
|
|
1393
|
+
const isoValue = toDateTimeISO(date);
|
|
1394
|
+
runValidation(isoValue);
|
|
1395
|
+
onValueChange(itemKey, fieldName, isoValue, value);
|
|
1396
|
+
}
|
|
1397
|
+
else {
|
|
1398
|
+
const isoValue = toDateOnlyISO(date);
|
|
1399
|
+
runValidation(isoValue);
|
|
1400
|
+
onValueChange(itemKey, fieldName, isoValue, value);
|
|
1401
|
+
}
|
|
1402
|
+
}, [isDateTime, currentValue, itemKey, fieldName, value, onValueChange, runValidation]);
|
|
1403
|
+
// Handle time selection from ComboBox
|
|
1404
|
+
const handleTimeChange = useCallback((_, option, _index, text) => {
|
|
1405
|
+
// Determine the time key from selection or typed text
|
|
1406
|
+
let timeKey = option?.key;
|
|
1407
|
+
if (!timeKey && text) {
|
|
1408
|
+
// Try to parse typed text like "2:30 PM"
|
|
1409
|
+
const match = text.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i);
|
|
1410
|
+
if (match) {
|
|
1411
|
+
let h = parseInt(match[1], 10);
|
|
1412
|
+
const m = parseInt(match[2], 10);
|
|
1413
|
+
const ampm = match[3]?.toUpperCase();
|
|
1414
|
+
if (ampm === 'PM' && h < 12)
|
|
1415
|
+
h += 12;
|
|
1416
|
+
if (ampm === 'AM' && h === 12)
|
|
1417
|
+
h = 0;
|
|
1418
|
+
timeKey = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (!timeKey)
|
|
1422
|
+
return;
|
|
1423
|
+
const [hours, minutes] = timeKey.split(':').map(Number);
|
|
1424
|
+
const baseDate = parseToDate(currentValue) || new Date();
|
|
1425
|
+
const newDate = new Date(baseDate);
|
|
1426
|
+
newDate.setHours(hours, minutes, 0, 0);
|
|
1427
|
+
const isoValue = toDateTimeISO(newDate);
|
|
1428
|
+
runValidation(isoValue);
|
|
1429
|
+
onValueChange(itemKey, fieldName, isoValue, value);
|
|
1430
|
+
}, [currentValue, itemKey, fieldName, value, onValueChange, runValidation]);
|
|
1431
|
+
// If not in edit mode, delegate to DateCell
|
|
1432
|
+
if (!isEditMode) {
|
|
1433
|
+
return (jsx(DateCell, { item: item, column: column, value: value, itemKey: itemKey }));
|
|
1434
|
+
}
|
|
1435
|
+
// Determine indicator class
|
|
1436
|
+
let indicatorClass = '';
|
|
1437
|
+
if (errorMessage) {
|
|
1438
|
+
indicatorClass = errorCellIndicatorClass;
|
|
1439
|
+
}
|
|
1440
|
+
else if (isDirty) {
|
|
1441
|
+
indicatorClass = dirtyCellIndicatorClass;
|
|
1442
|
+
}
|
|
1443
|
+
const wrapperClass = mergeStyles(editableCellInputWrapperClass, indicatorClass);
|
|
1444
|
+
if (isDateTime) {
|
|
1445
|
+
// DateTime: DatePicker + Time ComboBox side by side
|
|
1446
|
+
return (jsx("div", { className: editableCellClass, children: jsxs("div", { className: wrapperClass, children: [jsxs("div", { className: dateTimeRowClass, children: [jsx(DatePicker, { value: currentDate || undefined, onSelectDate: handleDateSelect, formatDate: formatDateForDisplay, placeholder: "Select date", styles: errorMessage ? dateTimePickerErrorStyles : dateTimePickerStyles, ariaLabel: `${column.name} date for ${item.key}`, allowTextInput: true }), jsx(ComboBox, { selectedKey: currentDate ? currentTimeKey : undefined, options: TIME_OPTIONS, onChange: handleTimeChange, placeholder: "Time", styles: timeComboBoxStyles, ariaLabel: `${column.name} time for ${item.key}`, allowFreeform: true, autoComplete: "on" })] }), errorMessage && (jsx("span", { className: cellErrorTextClass, title: errorMessage, children: errorMessage }))] }) }));
|
|
1447
|
+
}
|
|
1448
|
+
// Date-only: DatePicker
|
|
1449
|
+
return (jsx("div", { className: editableCellClass, children: jsxs("div", { className: wrapperClass, children: [jsx(DatePicker, { value: currentDate || undefined, onSelectDate: handleDateSelect, formatDate: formatDateForDisplay, placeholder: "Select date", styles: errorMessage ? datePickerErrorStyles : datePickerStyles, ariaLabel: `${column.name} for ${item.key}`, allowTextInput: true }), errorMessage && (jsx("span", { className: cellErrorTextClass, title: errorMessage, children: errorMessage }))] }) }));
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Styles for the dropdown in edit mode
|
|
1453
|
+
const dropdownStyles = {
|
|
1454
|
+
root: {
|
|
1455
|
+
width: '100%',
|
|
1456
|
+
},
|
|
1457
|
+
dropdown: {
|
|
1458
|
+
height: 28,
|
|
1459
|
+
minHeight: 28,
|
|
1460
|
+
border: 'none',
|
|
1461
|
+
backgroundColor: 'transparent',
|
|
1462
|
+
selectors: {
|
|
1463
|
+
':hover': {
|
|
1464
|
+
border: '1px solid #c8c6c4',
|
|
1465
|
+
backgroundColor: 'white',
|
|
1466
|
+
},
|
|
1467
|
+
':focus::after': {
|
|
1468
|
+
border: '1px solid #0078d4',
|
|
1469
|
+
},
|
|
1470
|
+
},
|
|
1471
|
+
},
|
|
1472
|
+
title: {
|
|
1473
|
+
height: 28,
|
|
1474
|
+
lineHeight: '26px',
|
|
1475
|
+
fontSize: 13,
|
|
1476
|
+
border: 'none',
|
|
1477
|
+
backgroundColor: 'transparent',
|
|
1478
|
+
},
|
|
1479
|
+
caretDownWrapper: {
|
|
1480
|
+
height: 28,
|
|
1481
|
+
lineHeight: '28px',
|
|
1482
|
+
},
|
|
1483
|
+
dropdownItem: {
|
|
1484
|
+
fontSize: 13,
|
|
1485
|
+
minHeight: 32,
|
|
1486
|
+
},
|
|
1487
|
+
dropdownItemSelected: {
|
|
1488
|
+
fontSize: 13,
|
|
1489
|
+
minHeight: 32,
|
|
1490
|
+
},
|
|
1491
|
+
};
|
|
1492
|
+
const dropdownErrorStyles = {
|
|
1493
|
+
...dropdownStyles,
|
|
1494
|
+
dropdown: {
|
|
1495
|
+
...dropdownStyles.dropdown,
|
|
1496
|
+
border: '1px solid #d13438',
|
|
1497
|
+
selectors: {
|
|
1498
|
+
':hover': {
|
|
1499
|
+
border: '1px solid #d13438',
|
|
1500
|
+
backgroundColor: 'white',
|
|
1501
|
+
},
|
|
1502
|
+
':focus::after': {
|
|
1503
|
+
border: '1px solid #d13438',
|
|
1504
|
+
},
|
|
1505
|
+
},
|
|
1506
|
+
},
|
|
1507
|
+
};
|
|
1508
|
+
/**
|
|
1509
|
+
* Editable option set cell renderer.
|
|
1510
|
+
* Displays as colored badge (OptionSetCell) in read mode, as Dropdown in edit mode.
|
|
1511
|
+
* Shows blue indicator when dirty, red indicator when has validation error.
|
|
1512
|
+
*/
|
|
1513
|
+
function EditableOptionSetCell({ item, column, value, itemKey, isEditMode, isDirty, editedValue, onValueChange, errorMessage, onValidationError, }) {
|
|
1514
|
+
const fieldName = column.fieldName || column.key;
|
|
1515
|
+
const currentValue = editedValue !== undefined ? editedValue : value;
|
|
1516
|
+
const isMulti = !!column.optionSetMultiSelect;
|
|
1517
|
+
const hasIcons = column.optionSetOptions?.some((o) => !!o.iconName) ?? false;
|
|
1518
|
+
// Build dropdown options. Keys preserve the option's value type (number or
|
|
1519
|
+
// string) — no Number() coercion. Single-select prepends a (None) option;
|
|
1520
|
+
// multi-select clears by de-selecting. `data` carries icon/color for rendering.
|
|
1521
|
+
const dropdownOptions = useMemo(() => {
|
|
1522
|
+
const options = isMulti ? [] : [{ key: '__none__', text: '(None)' }];
|
|
1523
|
+
column.optionSetOptions?.forEach((opt) => {
|
|
1524
|
+
options.push({
|
|
1525
|
+
key: opt.value,
|
|
1526
|
+
text: opt.label,
|
|
1527
|
+
data: { iconName: opt.iconName, color: opt.color },
|
|
1528
|
+
});
|
|
1529
|
+
});
|
|
1530
|
+
return options;
|
|
1531
|
+
}, [column.optionSetOptions, isMulti]);
|
|
1532
|
+
const validate = useCallback((next) => {
|
|
1533
|
+
const col = column;
|
|
1534
|
+
if (col.validate && onValidationError) {
|
|
1535
|
+
onValidationError(itemKey, fieldName, col.validate(next, itemKey));
|
|
1536
|
+
}
|
|
1537
|
+
}, [column, fieldName, itemKey, onValidationError]);
|
|
1538
|
+
// Single-select change → the picked value (type preserved) or null for (None).
|
|
1539
|
+
const handleChangeSingle = useCallback((_, option) => {
|
|
1540
|
+
if (!option)
|
|
1541
|
+
return;
|
|
1542
|
+
const newValue = option.key === '__none__' ? null : option.key;
|
|
1543
|
+
validate(newValue);
|
|
1544
|
+
onValueChange(itemKey, fieldName, newValue, value);
|
|
1545
|
+
}, [fieldName, itemKey, value, onValueChange, validate]);
|
|
1546
|
+
// Multi-select change → toggle the option into/out of the value array.
|
|
1547
|
+
const handleChangeMulti = useCallback((_, option) => {
|
|
1548
|
+
if (!option)
|
|
1549
|
+
return;
|
|
1550
|
+
const prev = Array.isArray(currentValue) ? [...currentValue] : [];
|
|
1551
|
+
const next = option.selected
|
|
1552
|
+
? [...prev, option.key]
|
|
1553
|
+
: prev.filter((k) => String(k) !== String(option.key));
|
|
1554
|
+
validate(next);
|
|
1555
|
+
onValueChange(itemKey, fieldName, next, value);
|
|
1556
|
+
}, [currentValue, fieldName, itemKey, value, onValueChange, validate]);
|
|
1557
|
+
// If not in edit mode, delegate to OptionSetCell for the colored badge display.
|
|
1558
|
+
if (!isEditMode) {
|
|
1559
|
+
return jsx(OptionSetCell, { item: item, column: column, value: value, itemKey: itemKey });
|
|
1560
|
+
}
|
|
1561
|
+
// Render per-option icon + label (only wired when some option has an icon).
|
|
1562
|
+
const onRenderOption = (option) => {
|
|
1563
|
+
if (!option)
|
|
1564
|
+
return null;
|
|
1565
|
+
const data = option.data;
|
|
1566
|
+
return (jsxs("span", { style: { display: 'inline-flex', alignItems: 'center', gap: 6 }, children: [data?.iconName && jsx(Icon, { iconName: data.iconName, style: { color: data.color, fontSize: 14 } }), jsx("span", { children: option.text })] }));
|
|
1567
|
+
};
|
|
1568
|
+
let indicatorClass = '';
|
|
1569
|
+
if (errorMessage) {
|
|
1570
|
+
indicatorClass = errorCellIndicatorClass;
|
|
1571
|
+
}
|
|
1572
|
+
else if (isDirty) {
|
|
1573
|
+
indicatorClass = dirtyCellIndicatorClass;
|
|
1574
|
+
}
|
|
1575
|
+
const wrapperClass = mergeStyles(editableCellInputWrapperClass, indicatorClass);
|
|
1576
|
+
const selectedKey = currentValue !== null && currentValue !== undefined ? currentValue : '__none__';
|
|
1577
|
+
// Cast to Fluent's homogeneous-array prop type; an optionset's keys are all the
|
|
1578
|
+
// same type at runtime (all numeric or all string), so this is safe.
|
|
1579
|
+
const selectedKeys = (Array.isArray(currentValue)
|
|
1580
|
+
? currentValue
|
|
1581
|
+
: []);
|
|
1582
|
+
return (jsx("div", { className: editableCellClass, children: jsxs("div", { className: wrapperClass, children: [jsx(Dropdown, { multiSelect: isMulti || undefined, selectedKey: isMulti ? undefined : selectedKey, selectedKeys: isMulti ? selectedKeys : undefined, options: dropdownOptions, onChange: isMulti ? handleChangeMulti : handleChangeSingle, onRenderOption: hasIcons ? onRenderOption : undefined, styles: errorMessage ? dropdownErrorStyles : dropdownStyles, ariaLabel: `${column.name} for ${item.key}` }), errorMessage && (jsx("span", { className: cellErrorTextClass, title: errorMessage, children: errorMessage }))] }) }));
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/** Normalize a consumer search result to a `LookupOption` (accepts `{id,name}` or `{key,text}`). */
|
|
1586
|
+
function normalizeLookupOption(o) {
|
|
1587
|
+
if (o && typeof o === 'object' && 'id' in o) {
|
|
1588
|
+
return { id: String(o.id), name: String(o.name ?? '') };
|
|
1589
|
+
}
|
|
1590
|
+
const alt = o;
|
|
1591
|
+
return { id: String(alt?.key ?? ''), name: String(alt?.text ?? '') };
|
|
1592
|
+
}
|
|
1593
|
+
// Styles for the search input in edit mode
|
|
1594
|
+
const lookupFieldStyles = {
|
|
1595
|
+
root: {
|
|
1596
|
+
width: '100%',
|
|
1597
|
+
},
|
|
1598
|
+
fieldGroup: {
|
|
1599
|
+
height: 28,
|
|
1600
|
+
minHeight: 28,
|
|
1601
|
+
border: 'none',
|
|
1602
|
+
backgroundColor: 'transparent',
|
|
1603
|
+
selectors: {
|
|
1604
|
+
':hover': {
|
|
1605
|
+
border: '1px solid #c8c6c4',
|
|
1606
|
+
backgroundColor: 'white',
|
|
1607
|
+
},
|
|
1608
|
+
':focus-within': {
|
|
1609
|
+
border: '1px solid #0078d4',
|
|
1610
|
+
backgroundColor: 'white',
|
|
1611
|
+
},
|
|
1612
|
+
},
|
|
1613
|
+
},
|
|
1614
|
+
field: {
|
|
1615
|
+
fontSize: 13,
|
|
1616
|
+
padding: '0 28px 0 8px', // Extra padding for clear button
|
|
1617
|
+
},
|
|
1618
|
+
};
|
|
1619
|
+
const lookupFieldErrorStyles = {
|
|
1620
|
+
...lookupFieldStyles,
|
|
1621
|
+
fieldGroup: {
|
|
1622
|
+
...lookupFieldStyles.fieldGroup,
|
|
1623
|
+
border: '1px solid #d13438',
|
|
1624
|
+
selectors: {
|
|
1625
|
+
':hover': {
|
|
1626
|
+
border: '1px solid #d13438',
|
|
1627
|
+
backgroundColor: 'white',
|
|
1628
|
+
},
|
|
1629
|
+
':focus-within': {
|
|
1630
|
+
border: '1px solid #d13438',
|
|
1631
|
+
backgroundColor: 'white',
|
|
1632
|
+
},
|
|
1633
|
+
},
|
|
1634
|
+
},
|
|
1635
|
+
};
|
|
1636
|
+
const readOnlyClass = mergeStyles({
|
|
1637
|
+
overflow: 'hidden',
|
|
1638
|
+
textOverflow: 'ellipsis',
|
|
1639
|
+
whiteSpace: 'nowrap',
|
|
1640
|
+
fontSize: 13,
|
|
1641
|
+
width: '100%',
|
|
1642
|
+
});
|
|
1643
|
+
/**
|
|
1644
|
+
* Extract the lookup display value from the item.
|
|
1645
|
+
* Handles various formats: OData annotations, expanded objects, formatted values.
|
|
1646
|
+
*/
|
|
1647
|
+
function extractLookupDisplayValue(item, column, value) {
|
|
1648
|
+
const fieldName = column.fieldName || column.key;
|
|
1649
|
+
// 1. Check for custom format function
|
|
1650
|
+
if (column.formatValue) {
|
|
1651
|
+
const formatted = column.formatValue(value, item);
|
|
1652
|
+
if (formatted && formatted !== '')
|
|
1653
|
+
return formatted;
|
|
1654
|
+
}
|
|
1655
|
+
// 2. Check for OData formatted value annotation on lookup
|
|
1656
|
+
const formattedKey = `_${fieldName}_value@OData.Community.Display.V1.FormattedValue`;
|
|
1657
|
+
const itemRecord = item;
|
|
1658
|
+
if (itemRecord[formattedKey] !== undefined) {
|
|
1659
|
+
return String(itemRecord[formattedKey]);
|
|
1660
|
+
}
|
|
1661
|
+
// 3. Check for lookupDisplayField on the item
|
|
1662
|
+
if (column.lookupDisplayField && itemRecord[column.lookupDisplayField] !== undefined) {
|
|
1663
|
+
return String(itemRecord[column.lookupDisplayField]);
|
|
1664
|
+
}
|
|
1665
|
+
// 4. Check for expanded lookup object
|
|
1666
|
+
if (value && typeof value === 'object') {
|
|
1667
|
+
const expanded = value;
|
|
1668
|
+
// Try common display field patterns
|
|
1669
|
+
if (expanded.fullname)
|
|
1670
|
+
return String(expanded.fullname);
|
|
1671
|
+
if (expanded.name)
|
|
1672
|
+
return String(expanded.name);
|
|
1673
|
+
if (expanded.title)
|
|
1674
|
+
return String(expanded.title);
|
|
1675
|
+
}
|
|
1676
|
+
return '';
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Extract the lookup ID value from the item.
|
|
1680
|
+
*/
|
|
1681
|
+
function extractLookupId(item, column, value) {
|
|
1682
|
+
const fieldName = column.fieldName || column.key;
|
|
1683
|
+
const itemRecord = item;
|
|
1684
|
+
// Check for _fieldname_value format (OData lookup reference)
|
|
1685
|
+
const lookupValueKey = `_${fieldName}_value`;
|
|
1686
|
+
if (itemRecord[lookupValueKey]) {
|
|
1687
|
+
return String(itemRecord[lookupValueKey]);
|
|
1688
|
+
}
|
|
1689
|
+
// Check expanded object
|
|
1690
|
+
if (value && typeof value === 'object') {
|
|
1691
|
+
const expanded = value;
|
|
1692
|
+
// Try to find the ID from expanded lookup
|
|
1693
|
+
const idField = column.lookupPrimaryIdAttribute || 'id';
|
|
1694
|
+
if (expanded[idField])
|
|
1695
|
+
return String(expanded[idField]);
|
|
1696
|
+
// Check for common ID patterns
|
|
1697
|
+
if (expanded.contactid)
|
|
1698
|
+
return String(expanded.contactid);
|
|
1699
|
+
if (expanded.accountid)
|
|
1700
|
+
return String(expanded.accountid);
|
|
1701
|
+
if (expanded.systemuserid)
|
|
1702
|
+
return String(expanded.systemuserid);
|
|
1703
|
+
}
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Editable lookup cell renderer.
|
|
1708
|
+
* Displays as a clickable link in read mode, as a searchable dropdown in edit mode.
|
|
1709
|
+
* Shows blue indicator when dirty, red indicator when has validation error.
|
|
1710
|
+
*/
|
|
1711
|
+
function EditableLookupCell({ item, column, value, itemKey, isEditMode, isDirty, editedValue, onValueChange, errorMessage, onValidationError, apiService, dynamicsBaseUrl, searchLookup, }) {
|
|
1712
|
+
const fieldName = column.fieldName || column.key;
|
|
1713
|
+
// Prefer the consumer search callback (direct prop or column config) over apiService.
|
|
1714
|
+
const search = searchLookup ?? column.lookupSearch;
|
|
1715
|
+
// Extract current lookup value
|
|
1716
|
+
const originalDisplayValue = extractLookupDisplayValue(item, column, value);
|
|
1717
|
+
const originalId = extractLookupId(item, column, value);
|
|
1718
|
+
// Current value (edited or original)
|
|
1719
|
+
const currentValue = useMemo(() => {
|
|
1720
|
+
if (editedValue !== undefined) {
|
|
1721
|
+
return editedValue;
|
|
1722
|
+
}
|
|
1723
|
+
if (originalId && originalDisplayValue) {
|
|
1724
|
+
return { id: originalId, name: originalDisplayValue };
|
|
1725
|
+
}
|
|
1726
|
+
return null;
|
|
1727
|
+
}, [editedValue, originalId, originalDisplayValue]);
|
|
1728
|
+
// Local state
|
|
1729
|
+
// Initialize with original display value to ensure it shows immediately
|
|
1730
|
+
const initialDisplayValue = currentValue?.name || originalDisplayValue || '';
|
|
1731
|
+
const [searchText, setSearchText] = useState(initialDisplayValue);
|
|
1732
|
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
1733
|
+
const [searchResults, setSearchResults] = useState([]);
|
|
1734
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
1735
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
1736
|
+
const [hasSearched, setHasSearched] = useState(false);
|
|
1737
|
+
const inputRef = useRef(null);
|
|
1738
|
+
const containerRef = useRef(null);
|
|
1739
|
+
const debounceTimerRef = useRef(null);
|
|
1740
|
+
// Sync search text when edited value changes OR when initial value is available
|
|
1741
|
+
useEffect(() => {
|
|
1742
|
+
const displayValue = currentValue?.name || originalDisplayValue || '';
|
|
1743
|
+
setSearchText(displayValue);
|
|
1744
|
+
}, [currentValue, originalDisplayValue]);
|
|
1745
|
+
// Handle Callout dismiss
|
|
1746
|
+
const handleCalloutDismiss = useCallback(() => {
|
|
1747
|
+
setIsDropdownOpen(false);
|
|
1748
|
+
}, []);
|
|
1749
|
+
// Execute the search API call
|
|
1750
|
+
const executeSearchQuery = useCallback(async (query, useContains) => {
|
|
1751
|
+
const searchField = column.lookupSearchField || 'name';
|
|
1752
|
+
const primaryIdAttr = column.lookupPrimaryIdAttribute || `${column.lookupTargetEntity}id`;
|
|
1753
|
+
const additionalFields = column.lookupSelectFields || '';
|
|
1754
|
+
// Build select fields
|
|
1755
|
+
const selectFields = [primaryIdAttr, searchField];
|
|
1756
|
+
if (additionalFields) {
|
|
1757
|
+
additionalFields.split(',').forEach(f => {
|
|
1758
|
+
const trimmed = f.trim();
|
|
1759
|
+
if (trimmed && !selectFields.includes(trimmed)) {
|
|
1760
|
+
selectFields.push(trimmed);
|
|
1761
|
+
}
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
// Build OData query
|
|
1765
|
+
let options = `?$select=${selectFields.join(',')}&$top=15&$orderby=${searchField} asc`;
|
|
1766
|
+
// Build filter conditions
|
|
1767
|
+
const filterConditions = [];
|
|
1768
|
+
// Add search filter if query is provided
|
|
1769
|
+
if (query.trim()) {
|
|
1770
|
+
const escapedQuery = query.replace(/'/g, "''");
|
|
1771
|
+
// Use contains for more flexible matching (finds "Nancy" anywhere in the name)
|
|
1772
|
+
// startswith is faster but less flexible
|
|
1773
|
+
if (useContains) {
|
|
1774
|
+
filterConditions.push(`contains(${searchField}, '${escapedQuery}')`);
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
filterConditions.push(`startswith(${searchField}, '${escapedQuery}')`);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
// Add custom filter from column config (e.g., "statecode eq 0" for active only)
|
|
1781
|
+
if (column.lookupFilter) {
|
|
1782
|
+
filterConditions.push(`(${column.lookupFilter})`);
|
|
1783
|
+
}
|
|
1784
|
+
// Combine filters with 'and'
|
|
1785
|
+
if (filterConditions.length > 0) {
|
|
1786
|
+
options += `&$filter=${filterConditions.join(' and ')}`;
|
|
1787
|
+
}
|
|
1788
|
+
const result = await apiService.retrieveMultipleRecords(column.lookupEntitySetName, options);
|
|
1789
|
+
return result.value.map((record) => ({
|
|
1790
|
+
id: String(record[primaryIdAttr]),
|
|
1791
|
+
name: String(record[searchField] || ''),
|
|
1792
|
+
}));
|
|
1793
|
+
}, [apiService, column.lookupEntitySetName, column.lookupSearchField, column.lookupPrimaryIdAttribute, column.lookupTargetEntity, column.lookupFilter, column.lookupSelectFields]);
|
|
1794
|
+
// Search for lookup records
|
|
1795
|
+
const performSearch = useCallback(async (query) => {
|
|
1796
|
+
// Fetch-free path: a consumer-supplied search callback (grid-kit's contract).
|
|
1797
|
+
// The consumer owns any startswith/contains fallback server-side.
|
|
1798
|
+
if (search) {
|
|
1799
|
+
setIsSearching(true);
|
|
1800
|
+
setHasSearched(true);
|
|
1801
|
+
try {
|
|
1802
|
+
const raw = await search(query);
|
|
1803
|
+
setSearchResults(raw.map(normalizeLookupOption));
|
|
1804
|
+
setActiveIndex(-1);
|
|
1805
|
+
}
|
|
1806
|
+
catch (err) {
|
|
1807
|
+
console.error('[EditableLookupCell] searchLookup error:', err);
|
|
1808
|
+
setSearchResults([]);
|
|
1809
|
+
}
|
|
1810
|
+
finally {
|
|
1811
|
+
setIsSearching(false);
|
|
1812
|
+
}
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
// apiService path (unchanged).
|
|
1816
|
+
if (!apiService || !column.lookupEntitySetName) {
|
|
1817
|
+
setSearchResults([]);
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
setIsSearching(true);
|
|
1821
|
+
setHasSearched(true);
|
|
1822
|
+
try {
|
|
1823
|
+
// First try with startswith (faster)
|
|
1824
|
+
let results = await executeSearchQuery(query, false);
|
|
1825
|
+
// If no results and we have a search query, try with contains (more flexible)
|
|
1826
|
+
if (results.length === 0 && query.trim()) {
|
|
1827
|
+
results = await executeSearchQuery(query, true);
|
|
1828
|
+
}
|
|
1829
|
+
setSearchResults(results);
|
|
1830
|
+
setActiveIndex(-1);
|
|
1831
|
+
}
|
|
1832
|
+
catch (err) {
|
|
1833
|
+
console.error('[EditableLookupCell] Search error:', err);
|
|
1834
|
+
setSearchResults([]);
|
|
1835
|
+
}
|
|
1836
|
+
finally {
|
|
1837
|
+
setIsSearching(false);
|
|
1838
|
+
}
|
|
1839
|
+
}, [search, apiService, column.lookupEntitySetName, executeSearchQuery]);
|
|
1840
|
+
// Debounced search
|
|
1841
|
+
const debouncedSearch = useCallback((query) => {
|
|
1842
|
+
if (debounceTimerRef.current) {
|
|
1843
|
+
window.clearTimeout(debounceTimerRef.current);
|
|
1844
|
+
}
|
|
1845
|
+
debounceTimerRef.current = window.setTimeout(() => {
|
|
1846
|
+
performSearch(query);
|
|
1847
|
+
}, 300);
|
|
1848
|
+
}, [performSearch]);
|
|
1849
|
+
// Handle input change
|
|
1850
|
+
const handleInputChange = useCallback((_, newValue) => {
|
|
1851
|
+
const val = newValue ?? '';
|
|
1852
|
+
setSearchText(val);
|
|
1853
|
+
// Open dropdown and trigger search
|
|
1854
|
+
setIsDropdownOpen(true);
|
|
1855
|
+
debouncedSearch(val);
|
|
1856
|
+
}, [debouncedSearch]);
|
|
1857
|
+
// Handle input focus - always load initial options
|
|
1858
|
+
const handleFocus = useCallback(() => {
|
|
1859
|
+
setIsDropdownOpen(true);
|
|
1860
|
+
// Always do an empty search on focus to populate with available options
|
|
1861
|
+
// This ensures users see options even if there's a current value
|
|
1862
|
+
if (!hasSearched || searchResults.length === 0) {
|
|
1863
|
+
// Search with empty string to get initial list of all options
|
|
1864
|
+
performSearch('');
|
|
1865
|
+
}
|
|
1866
|
+
}, [hasSearched, performSearch, searchResults.length]);
|
|
1867
|
+
// Handle selection of a lookup option
|
|
1868
|
+
const handleSelect = useCallback((option) => {
|
|
1869
|
+
setIsDropdownOpen(false);
|
|
1870
|
+
if (option === null) {
|
|
1871
|
+
// Clear selection
|
|
1872
|
+
setSearchText('');
|
|
1873
|
+
onValueChange(itemKey, fieldName, null, value);
|
|
1874
|
+
}
|
|
1875
|
+
else {
|
|
1876
|
+
// Set new selection
|
|
1877
|
+
setSearchText(option.name);
|
|
1878
|
+
const newValue = {
|
|
1879
|
+
id: option.id,
|
|
1880
|
+
name: option.name,
|
|
1881
|
+
entitySetName: column.lookupEntitySetName,
|
|
1882
|
+
};
|
|
1883
|
+
onValueChange(itemKey, fieldName, newValue, value);
|
|
1884
|
+
}
|
|
1885
|
+
// Clear any validation errors
|
|
1886
|
+
if (onValidationError) {
|
|
1887
|
+
onValidationError(itemKey, fieldName, undefined);
|
|
1888
|
+
}
|
|
1889
|
+
}, [itemKey, fieldName, value, column.lookupEntitySetName, onValueChange, onValidationError]);
|
|
1890
|
+
// Handle keyboard navigation
|
|
1891
|
+
const handleKeyDown = useCallback((event) => {
|
|
1892
|
+
if (!isDropdownOpen) {
|
|
1893
|
+
if (event.key === 'ArrowDown' || event.key === 'Enter') {
|
|
1894
|
+
setIsDropdownOpen(true);
|
|
1895
|
+
if (!hasSearched) {
|
|
1896
|
+
performSearch(searchText);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
// Total items: 1 (None option) + searchResults.length
|
|
1902
|
+
const totalItems = searchResults.length + 1;
|
|
1903
|
+
switch (event.key) {
|
|
1904
|
+
case 'ArrowDown':
|
|
1905
|
+
event.preventDefault();
|
|
1906
|
+
setActiveIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
|
1907
|
+
break;
|
|
1908
|
+
case 'ArrowUp':
|
|
1909
|
+
event.preventDefault();
|
|
1910
|
+
setActiveIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
|
1911
|
+
break;
|
|
1912
|
+
case 'Enter':
|
|
1913
|
+
event.preventDefault();
|
|
1914
|
+
if (activeIndex === 0) {
|
|
1915
|
+
// None option selected
|
|
1916
|
+
handleSelect(null);
|
|
1917
|
+
}
|
|
1918
|
+
else if (activeIndex > 0 && activeIndex <= searchResults.length) {
|
|
1919
|
+
// Regular option selected
|
|
1920
|
+
handleSelect(searchResults[activeIndex - 1]);
|
|
1921
|
+
}
|
|
1922
|
+
else if (searchResults.length === 1) {
|
|
1923
|
+
// Auto-select single result
|
|
1924
|
+
handleSelect(searchResults[0]);
|
|
1925
|
+
}
|
|
1926
|
+
break;
|
|
1927
|
+
case 'Tab':
|
|
1928
|
+
// Select active or first result on tab
|
|
1929
|
+
if (activeIndex === 0) {
|
|
1930
|
+
handleSelect(null);
|
|
1931
|
+
}
|
|
1932
|
+
else if (activeIndex > 0 && activeIndex <= searchResults.length) {
|
|
1933
|
+
handleSelect(searchResults[activeIndex - 1]);
|
|
1934
|
+
}
|
|
1935
|
+
else if (searchResults.length > 0) {
|
|
1936
|
+
handleSelect(searchResults[0]);
|
|
1937
|
+
}
|
|
1938
|
+
setIsDropdownOpen(false);
|
|
1939
|
+
break;
|
|
1940
|
+
case 'Escape':
|
|
1941
|
+
event.preventDefault();
|
|
1942
|
+
setIsDropdownOpen(false);
|
|
1943
|
+
setSearchText(currentValue?.name || '');
|
|
1944
|
+
break;
|
|
1945
|
+
}
|
|
1946
|
+
}, [isDropdownOpen, activeIndex, searchResults, hasSearched, performSearch, searchText, handleSelect, currentValue]);
|
|
1947
|
+
// Handle clear button click
|
|
1948
|
+
const handleClear = useCallback((event) => {
|
|
1949
|
+
event.stopPropagation();
|
|
1950
|
+
handleSelect(null);
|
|
1951
|
+
}, [handleSelect]);
|
|
1952
|
+
// Handle click on read-mode link (open in Dynamics)
|
|
1953
|
+
const handleLinkClick = useCallback(() => {
|
|
1954
|
+
if (!dynamicsBaseUrl || !currentValue?.id || !column.lookupTargetEntity)
|
|
1955
|
+
return;
|
|
1956
|
+
const url = `${dynamicsBaseUrl}/main.aspx?etn=${column.lookupTargetEntity}&id=${currentValue.id}&pagetype=entityrecord`;
|
|
1957
|
+
window.open(url, '_blank');
|
|
1958
|
+
}, [dynamicsBaseUrl, currentValue, column.lookupTargetEntity]);
|
|
1959
|
+
// Handle link keydown for accessibility
|
|
1960
|
+
const handleLinkKeyDown = useCallback((event) => {
|
|
1961
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
1962
|
+
event.preventDefault();
|
|
1963
|
+
handleLinkClick();
|
|
1964
|
+
}
|
|
1965
|
+
}, [handleLinkClick]);
|
|
1966
|
+
// ============================================================================
|
|
1967
|
+
// Read Mode Rendering
|
|
1968
|
+
// ============================================================================
|
|
1969
|
+
if (!isEditMode) {
|
|
1970
|
+
const displayValue = currentValue?.name || originalDisplayValue || '-';
|
|
1971
|
+
// Consumer-supplied click handler (e.g. grid-kit navigation) wins over the
|
|
1972
|
+
// built-in dynamicsBaseUrl link — keeps read-mode parity with LookupCell.
|
|
1973
|
+
if (column.onLinkClick && displayValue !== '-') {
|
|
1974
|
+
const onClick = () => column.onLinkClick(item);
|
|
1975
|
+
return (jsx("div", { className: editableCellClass, children: jsx("span", { className: lookupReadModeLinkClass, onClick: onClick, onKeyDown: (e) => {
|
|
1976
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1977
|
+
e.preventDefault();
|
|
1978
|
+
onClick();
|
|
1979
|
+
}
|
|
1980
|
+
}, role: "button", tabIndex: 0, title: displayValue, children: displayValue }) }));
|
|
1981
|
+
}
|
|
1982
|
+
// If we have a link and a valid lookup, show as clickable link
|
|
1983
|
+
if (dynamicsBaseUrl && column.lookupTargetEntity && currentValue?.id && displayValue !== '-') {
|
|
1984
|
+
return (jsx("div", { className: editableCellClass, children: jsx("span", { className: lookupReadModeLinkClass, onClick: handleLinkClick, onKeyDown: handleLinkKeyDown, role: "button", tabIndex: 0, title: `Open ${displayValue} in Dynamics 365`, children: displayValue }) }));
|
|
1985
|
+
}
|
|
1986
|
+
// Plain text display
|
|
1987
|
+
return (jsx("div", { className: editableCellClass, children: jsx("span", { className: readOnlyClass, title: displayValue !== '-' ? displayValue : undefined, children: displayValue }) }));
|
|
1988
|
+
}
|
|
1989
|
+
// ============================================================================
|
|
1990
|
+
// Edit Mode Rendering
|
|
1991
|
+
// ============================================================================
|
|
1992
|
+
// Determine indicator class
|
|
1993
|
+
let indicatorClass = '';
|
|
1994
|
+
if (errorMessage) {
|
|
1995
|
+
indicatorClass = errorCellIndicatorClass;
|
|
1996
|
+
}
|
|
1997
|
+
else if (isDirty) {
|
|
1998
|
+
indicatorClass = dirtyCellIndicatorClass;
|
|
1999
|
+
}
|
|
2000
|
+
const wrapperClass = mergeStyles(editableCellInputWrapperClass, indicatorClass);
|
|
2001
|
+
// Callout styles
|
|
2002
|
+
const calloutStyles = {
|
|
2003
|
+
root: {
|
|
2004
|
+
padding: 0,
|
|
2005
|
+
boxShadow: '0 3px 6px rgba(0, 0, 0, 0.16)',
|
|
2006
|
+
},
|
|
2007
|
+
calloutMain: {
|
|
2008
|
+
padding: 0,
|
|
2009
|
+
maxHeight: 200,
|
|
2010
|
+
overflowY: 'auto',
|
|
2011
|
+
minWidth: 200,
|
|
2012
|
+
},
|
|
2013
|
+
};
|
|
2014
|
+
return (jsx("div", { className: editableCellClass, children: jsxs("div", { className: wrapperClass, children: [jsxs("div", { className: lookupCellContainerClass, ref: containerRef, children: [jsx(TextField, { componentRef: inputRef, value: searchText, onChange: handleInputChange, onFocus: handleFocus, onKeyDown: handleKeyDown, borderless: true, styles: errorMessage ? lookupFieldErrorStyles : lookupFieldStyles, ariaLabel: `${column.name} lookup for ${item.key}`, placeholder: originalDisplayValue || "Search...", autoComplete: "off" }), searchText && (jsx("button", { className: lookupClearButtonClass, onClick: handleClear, type: "button", "aria-label": "Clear selection", tabIndex: -1, children: jsx(Icon, { iconName: "Cancel", style: { fontSize: 10 } }) }))] }), isDropdownOpen && containerRef.current && (jsxs(Callout, { target: containerRef.current, onDismiss: handleCalloutDismiss, isBeakVisible: false, directionalHint: DirectionalHint.bottomLeftEdge, styles: calloutStyles, calloutMaxHeight: 200, setInitialFocus: false, children: [jsx("div", { className: mergeStyles(lookupDropdownNoneOptionClass, activeIndex === 0 ? lookupDropdownItemActiveClass : ''), onClick: () => handleSelect(null), role: "option", "aria-selected": activeIndex === 0, children: "(None)" }), isSearching && (jsx("div", { className: lookupLoadingClass, children: "Searching..." })), !isSearching && searchResults.length > 0 && searchResults.map((option, index) => (jsx("div", { className: mergeStyles(lookupDropdownItemClass, activeIndex === index + 1 ? lookupDropdownItemActiveClass : ''), onClick: () => handleSelect(option), role: "option", "aria-selected": activeIndex === index + 1, children: option.name }, option.id))), !isSearching && hasSearched && searchResults.length === 0 && (jsx("div", { className: lookupNoResultsClass, children: "No results found" }))] })), errorMessage && (jsx("span", { className: cellErrorTextClass, title: errorMessage, children: errorMessage }))] }) }));
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
const btnBaseStyle = {
|
|
2018
|
+
background: 'none',
|
|
2019
|
+
border: 'none',
|
|
2020
|
+
padding: 0,
|
|
2021
|
+
margin: 0,
|
|
2022
|
+
cursor: 'pointer',
|
|
2023
|
+
lineHeight: 1,
|
|
2024
|
+
};
|
|
2025
|
+
const starBtnStyle = { ...btnBaseStyle, fontSize: 16 };
|
|
2026
|
+
/**
|
|
2027
|
+
* Editable rating cell renderer.
|
|
2028
|
+
* Read mode delegates to `RatingCell` (stars or a single sentiment emoji);
|
|
2029
|
+
* edit mode renders clickable stars / faces (click to set, click the selected
|
|
2030
|
+
* one again to clear, arrow keys to adjust). Shows blue indicator when dirty,
|
|
2031
|
+
* red when it has a validation error.
|
|
2032
|
+
*
|
|
2033
|
+
* NOTE: `ratingStyle: 'emoji'` renders Fluent `<Icon>`s — the host must have
|
|
2034
|
+
* called `initializeIcons()`.
|
|
2035
|
+
*/
|
|
2036
|
+
function EditableRatingCell({ item, column, value, itemKey, isEditMode, isDirty, editedValue, onValueChange, errorMessage, onValidationError, }) {
|
|
2037
|
+
const fieldName = column.fieldName || column.key;
|
|
2038
|
+
const isEmoji = column.ratingStyle === 'emoji';
|
|
2039
|
+
const count = isEmoji
|
|
2040
|
+
? clampFaceCount(column.ratingFaceCount, column.ratingMax)
|
|
2041
|
+
: column.ratingMax && column.ratingMax > 0
|
|
2042
|
+
? Math.floor(column.ratingMax)
|
|
2043
|
+
: 5;
|
|
2044
|
+
const current = editedValue !== undefined ? editedValue : value;
|
|
2045
|
+
const rawCurrent = typeof current === 'number' ? current : Number(current);
|
|
2046
|
+
const selected = Number.isFinite(rawCurrent) ? Math.min(count, Math.max(0, Math.round(rawCurrent))) : 0;
|
|
2047
|
+
const commit = useCallback((n) => {
|
|
2048
|
+
const next = n <= 0 ? null : n;
|
|
2049
|
+
const col = column;
|
|
2050
|
+
if (col.validate && onValidationError) {
|
|
2051
|
+
onValidationError(itemKey, fieldName, col.validate(next, itemKey));
|
|
2052
|
+
}
|
|
2053
|
+
onValueChange(itemKey, fieldName, next, value);
|
|
2054
|
+
}, [column, fieldName, itemKey, value, onValueChange, onValidationError]);
|
|
2055
|
+
// Read mode → shared display (stars or single sentiment face).
|
|
2056
|
+
if (!isEditMode) {
|
|
2057
|
+
return jsx(RatingCell, { item: item, column: column, value: value, itemKey: itemKey });
|
|
2058
|
+
}
|
|
2059
|
+
const handleKeyDown = (e) => {
|
|
2060
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
|
2061
|
+
e.preventDefault();
|
|
2062
|
+
commit(Math.min(count, selected + 1));
|
|
2063
|
+
}
|
|
2064
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
|
2065
|
+
e.preventDefault();
|
|
2066
|
+
commit(Math.max(0, selected - 1));
|
|
2067
|
+
}
|
|
2068
|
+
else if (e.key === 'Home') {
|
|
2069
|
+
e.preventDefault();
|
|
2070
|
+
commit(0);
|
|
2071
|
+
}
|
|
2072
|
+
else if (e.key === 'End') {
|
|
2073
|
+
e.preventDefault();
|
|
2074
|
+
commit(count);
|
|
2075
|
+
}
|
|
2076
|
+
};
|
|
2077
|
+
let indicatorClass = '';
|
|
2078
|
+
if (errorMessage)
|
|
2079
|
+
indicatorClass = errorCellIndicatorClass;
|
|
2080
|
+
else if (isDirty)
|
|
2081
|
+
indicatorClass = dirtyCellIndicatorClass;
|
|
2082
|
+
const wrapperClass = mergeStyles(editableCellInputWrapperClass, indicatorClass);
|
|
2083
|
+
const buttons = [];
|
|
2084
|
+
for (let i = 1; i <= count; i++) {
|
|
2085
|
+
// Click the currently-selected value again to clear it.
|
|
2086
|
+
const onClick = () => commit(i === selected ? 0 : i);
|
|
2087
|
+
if (isEmoji) {
|
|
2088
|
+
const set = EMOJI_SETS[count];
|
|
2089
|
+
const isSel = i === selected;
|
|
2090
|
+
const useColor = column.ratingUseSentimentColor !== false;
|
|
2091
|
+
const color = isSel
|
|
2092
|
+
? useColor
|
|
2093
|
+
? EMOJI_COLORS[count][i - 1]
|
|
2094
|
+
: column.ratingColor || '#0078d4'
|
|
2095
|
+
: '#c8c6c4';
|
|
2096
|
+
buttons.push(jsx("button", { type: "button", onClick: onClick, title: set.labels[i - 1], "aria-label": set.labels[i - 1], "aria-hidden": true, tabIndex: -1, style: btnBaseStyle, children: jsx(Icon, { iconName: set.icons[i - 1], style: { color, fontSize: 18 } }) }, i));
|
|
2097
|
+
}
|
|
2098
|
+
else {
|
|
2099
|
+
const filledColor = column.ratingColor || '#ffb900';
|
|
2100
|
+
buttons.push(jsx("button", { type: "button", onClick: onClick, "aria-label": `${i} of ${count}`, "aria-hidden": true, tabIndex: -1, style: { ...starBtnStyle, color: i <= selected ? filledColor : '#c8c6c4' }, children: "\u2605" }, i));
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
return (jsx("div", { className: editableCellClass, children: jsxs("div", { className: wrapperClass, children: [jsx("div", { role: "slider", "aria-valuemin": 0, "aria-valuemax": count, "aria-valuenow": selected, "aria-label": `${column.name} for ${item.key}`, tabIndex: 0, onKeyDown: handleKeyDown, style: { display: 'flex', gap: 2, alignItems: 'center' }, children: buttons }), errorMessage && (jsx("span", { className: cellErrorTextClass, title: errorMessage, children: errorMessage }))] }) }));
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
export { ColoredCell, CompositeCell, CurrencyCell, DateCell, EditableDateCell, EditableLookupCell, EditableNumberCell, EditableOptionSetCell, EditableRatingCell, EditableTextCell, IconCell, LinkCell, LookupCell, NumberCell, OptionSetCell, ProgressBarCell, RatingCell, TextCell, ToggleCell, cellClass, cellErrorTextClass, coloredBadgeClass, compositeCellClass, currencyCellClass, dirtyCellIndicatorClass, editableCellClass, editableCellInputWrapperClass, errorCellIndicatorClass, getDetailsListStyles, getRowStyles, getRowStylesWithSelection, iconButtonCellClass, linkCellClass, lookupCellContainerClass, lookupClearButtonClass, lookupDropdownClass, lookupDropdownItemActiveClass, lookupDropdownItemClass, lookupDropdownNoneOptionClass, lookupLoadingClass, lookupNoResultsClass, lookupReadModeLinkClass, progressBarBackgroundClass, progressBarContainerClass, progressBarFillClass, progressBarLabelClass, ratingCellClass, ratingStarClass };
|
|
2107
|
+
//# sourceMappingURL=index.esm.js.map
|