@dataverse-kit/grid-kit 0.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/adapters/CalloutCell.d.ts +24 -0
- package/dist/adapters/CalloutCell.d.ts.map +1 -0
- package/dist/adapters/EditCellWrapper.d.ts +13 -0
- package/dist/adapters/EditCellWrapper.d.ts.map +1 -0
- package/dist/adapters/context.d.ts +45 -0
- package/dist/adapters/context.d.ts.map +1 -0
- package/dist/adapters/fromGridCustomizerDefinition.d.ts +43 -0
- package/dist/adapters/fromGridCustomizerDefinition.d.ts.map +1 -0
- package/dist/adapters/index.d.ts +10 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/resolveGridFromDefinition.d.ts +92 -0
- package/dist/adapters/resolveGridFromDefinition.d.ts.map +1 -0
- package/dist/adapters/toDetailsListColumns.d.ts +5 -0
- package/dist/adapters/toDetailsListColumns.d.ts.map +1 -0
- package/dist/adapters/toGridCustomizerOverrides.d.ts +46 -0
- package/dist/adapters/toGridCustomizerOverrides.d.ts.map +1 -0
- package/dist/core/aggregate.d.ts +35 -0
- package/dist/core/aggregate.d.ts.map +1 -0
- package/dist/core/coercion.d.ts +9 -0
- package/dist/core/coercion.d.ts.map +1 -0
- package/dist/core/colorResolver.d.ts +15 -0
- package/dist/core/colorResolver.d.ts.map +1 -0
- package/dist/core/filter.d.ts +40 -0
- package/dist/core/filter.d.ts.map +1 -0
- package/dist/core/formatters.d.ts +9 -0
- package/dist/core/formatters.d.ts.map +1 -0
- package/dist/core/index.d.ts +12 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/thresholds.d.ts +15 -0
- package/dist/core/thresholds.d.ts.map +1 -0
- package/dist/dnd/DroppableCell.d.ts +16 -0
- package/dist/dnd/DroppableCell.d.ts.map +1 -0
- package/dist/dnd/FileDropCell.d.ts +25 -0
- package/dist/dnd/FileDropCell.d.ts.map +1 -0
- package/dist/dnd/index.d.ts +7 -0
- package/dist/dnd/index.d.ts.map +1 -0
- package/dist/dnd/useFileDrop.d.ts +36 -0
- package/dist/dnd/useFileDrop.d.ts.map +1 -0
- package/dist/export/GridExportDialog.d.ts +29 -0
- package/dist/export/GridExportDialog.d.ts.map +1 -0
- package/dist/export/exportGrid.d.ts +17 -0
- package/dist/export/exportGrid.d.ts.map +1 -0
- package/dist/export/index.d.ts +4 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/hosts/CardGrid.d.ts +12 -0
- package/dist/hosts/CardGrid.d.ts.map +1 -0
- package/dist/hosts/DataGrid.d.ts +10 -0
- package/dist/hosts/DataGrid.d.ts.map +1 -0
- package/dist/hosts/DetailsGrid.d.ts +3 -0
- package/dist/hosts/DetailsGrid.d.ts.map +1 -0
- package/dist/hosts/FocusedViewGrid.d.ts +10 -0
- package/dist/hosts/FocusedViewGrid.d.ts.map +1 -0
- package/dist/hosts/GridAggregateFooter.d.ts +36 -0
- package/dist/hosts/GridAggregateFooter.d.ts.map +1 -0
- package/dist/hosts/GridColumnChooser.d.ts +27 -0
- package/dist/hosts/GridColumnChooser.d.ts.map +1 -0
- package/dist/hosts/GridFilterBuilder.d.ts +32 -0
- package/dist/hosts/GridFilterBuilder.d.ts.map +1 -0
- package/dist/hosts/GridPaginationFooter.d.ts +11 -0
- package/dist/hosts/GridPaginationFooter.d.ts.map +1 -0
- package/dist/hosts/GroupedGrid.d.ts +9 -0
- package/dist/hosts/GroupedGrid.d.ts.map +1 -0
- package/dist/hosts/ReadOnlyGrid.d.ts +9 -0
- package/dist/hosts/ReadOnlyGrid.d.ts.map +1 -0
- package/dist/hosts/buildGroups.d.ts +14 -0
- package/dist/hosts/buildGroups.d.ts.map +1 -0
- package/dist/hosts/cardLayout.d.ts +22 -0
- package/dist/hosts/cardLayout.d.ts.map +1 -0
- package/dist/hosts/fill.d.ts +10 -0
- package/dist/hosts/fill.d.ts.map +1 -0
- package/dist/hosts/index.d.ts +19 -0
- package/dist/hosts/index.d.ts.map +1 -0
- package/dist/hosts/nested/NestedCardParent.d.ts +23 -0
- package/dist/hosts/nested/NestedCardParent.d.ts.map +1 -0
- package/dist/hosts/nested/NestedGrid.d.ts +17 -0
- package/dist/hosts/nested/NestedGrid.d.ts.map +1 -0
- package/dist/hosts/nested/NestedInline.d.ts +18 -0
- package/dist/hosts/nested/NestedInline.d.ts.map +1 -0
- package/dist/hosts/nested/NestedTriggerCell.d.ts +12 -0
- package/dist/hosts/nested/NestedTriggerCell.d.ts.map +1 -0
- package/dist/hosts/nested/labels.d.ts +10 -0
- package/dist/hosts/nested/labels.d.ts.map +1 -0
- package/dist/hosts/nested/useChildren.d.ts +15 -0
- package/dist/hosts/nested/useChildren.d.ts.map +1 -0
- package/dist/hosts/rowContextMenu.d.ts +11 -0
- package/dist/hosts/rowContextMenu.d.ts.map +1 -0
- package/dist/hosts/toolbar/GridToolbar.d.ts +9 -0
- package/dist/hosts/toolbar/GridToolbar.d.ts.map +1 -0
- package/dist/hosts/useEditState.d.ts +25 -0
- package/dist/hosts/useEditState.d.ts.map +1 -0
- package/dist/hosts/useGridContext.d.ts +15 -0
- package/dist/hosts/useGridContext.d.ts.map +1 -0
- package/dist/hosts/useRowContextMenu.d.ts +18 -0
- package/dist/hosts/useRowContextMenu.d.ts.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +2783 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +2847 -0
- package/dist/index.js.map +1 -0
- package/dist/navigation/index.d.ts +3 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/navigateTo.d.ts +71 -0
- package/dist/navigation/navigateTo.d.ts.map +1 -0
- package/dist/registry/columnMapping.d.ts +15 -0
- package/dist/registry/columnMapping.d.ts.map +1 -0
- package/dist/registry/createCellRegistry.d.ts +8 -0
- package/dist/registry/createCellRegistry.d.ts.map +1 -0
- package/dist/registry/index.d.ts +4 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/renderers.d.ts +15 -0
- package/dist/registry/renderers.d.ts.map +1 -0
- package/dist/types/index.d.ts +488 -0
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,2783 @@
|
|
|
1
|
+
import { formatDateTime, formatDate, formatNumber, formatCurrency, exportToFile, generateDefaultFilename } from '@khester/dynamics-utils';
|
|
2
|
+
export { getContrastColor, normalizeHexColor } from '@khester/dynamics-utils';
|
|
3
|
+
import { EditableTextCell, TextCell, LinkCell, EditableLookupCell, LookupCell, EditableOptionSetCell, OptionSetCell, EditableNumberCell, CurrencyCell, NumberCell, EditableDateCell, DateCell, ToggleCell, IconCell, ProgressBarCell, ColoredCell, EditableRatingCell, RatingCell, CompositeCell, getDetailsListStyles } from '@khester/dynamics-cell-renderers';
|
|
4
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
5
|
+
import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
6
|
+
import { Callout, DirectionalHint, Dialog, DialogType, Text, Separator, ChoiceGroup, Checkbox, TextField, DialogFooter, PrimaryButton, DefaultButton, ContextualMenu, Stack, CommandBar, SearchBox, Panel, PanelType, IconButton, Dropdown, DatePicker, Selection, ShimmeredDetailsList, DetailsListLayoutMode, SelectionMode, mergeStyles, Spinner, ActionButton, Icon } from '@fluentui/react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Framework-agnostic value coercion. No React / no Fluent imports (enforced by
|
|
10
|
+
* the package ESLint guard on src/core/**).
|
|
11
|
+
*/
|
|
12
|
+
function toNumber(value) {
|
|
13
|
+
if (value === null || value === undefined || value === '')
|
|
14
|
+
return null;
|
|
15
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
16
|
+
return Number.isFinite(n) ? n : null;
|
|
17
|
+
}
|
|
18
|
+
function toBool(value) {
|
|
19
|
+
if (typeof value === 'boolean')
|
|
20
|
+
return value;
|
|
21
|
+
if (typeof value === 'number')
|
|
22
|
+
return value !== 0;
|
|
23
|
+
const s = String(value).toLowerCase();
|
|
24
|
+
return s === 'true' || s === '1' || s === 'yes';
|
|
25
|
+
}
|
|
26
|
+
function toDate(value) {
|
|
27
|
+
if (value instanceof Date)
|
|
28
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
29
|
+
if (value === null || value === undefined || value === '')
|
|
30
|
+
return null;
|
|
31
|
+
const d = new Date(value);
|
|
32
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
33
|
+
}
|
|
34
|
+
function toDisplayString(value) {
|
|
35
|
+
return value !== null && value !== undefined ? String(value) : '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Framework-agnostic threshold resolution (progress / KPI coloring).
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a value to a threshold level.
|
|
43
|
+
* - `direction: 'desc'` (default): lower is worse (e.g. completion %).
|
|
44
|
+
* - `direction: 'asc'`: higher is worse (e.g. error count).
|
|
45
|
+
*/
|
|
46
|
+
function resolveThreshold(value, thresholds, direction = 'desc') {
|
|
47
|
+
if (!thresholds)
|
|
48
|
+
return 'ok';
|
|
49
|
+
const { warning, danger } = thresholds;
|
|
50
|
+
if (direction === 'desc') {
|
|
51
|
+
if (danger !== undefined && value <= danger)
|
|
52
|
+
return 'danger';
|
|
53
|
+
if (warning !== undefined && value <= warning)
|
|
54
|
+
return 'warning';
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
if (danger !== undefined && value >= danger)
|
|
58
|
+
return 'danger';
|
|
59
|
+
if (warning !== undefined && value >= warning)
|
|
60
|
+
return 'warning';
|
|
61
|
+
}
|
|
62
|
+
return 'ok';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Framework-agnostic cell-color resolution. Wraps (does not reimplement)
|
|
67
|
+
* @khester/dynamics-utils contrast + hex helpers.
|
|
68
|
+
*/
|
|
69
|
+
/** Resolve the background color for a cell value, or undefined for none. */
|
|
70
|
+
function resolveCellColor(value, res) {
|
|
71
|
+
if (value === null || value === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
if (res.getBackgroundColor)
|
|
74
|
+
return res.getBackgroundColor(value);
|
|
75
|
+
if (res.colorMap) {
|
|
76
|
+
const key = typeof value === 'number' ? value : String(value);
|
|
77
|
+
return res.colorMap[key];
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Framework-agnostic display-value formatting. Wraps @khester/dynamics-utils.
|
|
84
|
+
*
|
|
85
|
+
* This produces the FALLBACK `formattedValue` only — adapters prefer a
|
|
86
|
+
* host-supplied value (e.g. Dataverse `@OData…FormattedValue`) when present and
|
|
87
|
+
* never recompute over it.
|
|
88
|
+
*/
|
|
89
|
+
/** Compute a fallback display string for a value given its renderer type. */
|
|
90
|
+
function computeFormattedValue(value, rendererType, config = {}) {
|
|
91
|
+
if (value === null || value === undefined || value === '')
|
|
92
|
+
return '';
|
|
93
|
+
const { currencyCode = 'USD', locale = 'en-US', dateOptions } = config;
|
|
94
|
+
switch (rendererType) {
|
|
95
|
+
case 'currency': {
|
|
96
|
+
const n = toNumber(value);
|
|
97
|
+
return n === null ? toDisplayString(value) : formatCurrency(n, currencyCode, locale);
|
|
98
|
+
}
|
|
99
|
+
case 'number': {
|
|
100
|
+
const n = toNumber(value);
|
|
101
|
+
return n === null ? toDisplayString(value) : formatNumber(n, locale);
|
|
102
|
+
}
|
|
103
|
+
case 'date':
|
|
104
|
+
return formatDate(String(value), locale, dateOptions);
|
|
105
|
+
case 'datetime':
|
|
106
|
+
return formatDateTime(String(value), locale);
|
|
107
|
+
default:
|
|
108
|
+
return toDisplayString(value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Framework-agnostic footer aggregates. No React / no Fluent imports (enforced by
|
|
114
|
+
* the package ESLint guard on src/core/**). Used by the host footer renderers.
|
|
115
|
+
*/
|
|
116
|
+
/**
|
|
117
|
+
* Compute one aggregate over a column's values.
|
|
118
|
+
* - `count` counts ALL rows (including null/empty values).
|
|
119
|
+
* - `sum`/`avg`/`min`/`max` consider only numeric-coercible values; over an empty
|
|
120
|
+
* numeric set they return `null` (so a footer renders blank, never NaN/Infinity).
|
|
121
|
+
* Implemented with a single pass (no `Math.min(...nums)` spread, which blows the
|
|
122
|
+
* argument limit on large grids).
|
|
123
|
+
*/
|
|
124
|
+
function computeAggregate(items, fieldName, fn) {
|
|
125
|
+
if (fn === 'count')
|
|
126
|
+
return items.length;
|
|
127
|
+
let count = 0;
|
|
128
|
+
let sum = 0;
|
|
129
|
+
let min = Infinity;
|
|
130
|
+
let max = -Infinity;
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
const n = toNumber(item?.[fieldName]);
|
|
133
|
+
if (n === null)
|
|
134
|
+
continue;
|
|
135
|
+
count++;
|
|
136
|
+
sum += n;
|
|
137
|
+
if (n < min)
|
|
138
|
+
min = n;
|
|
139
|
+
if (n > max)
|
|
140
|
+
max = n;
|
|
141
|
+
}
|
|
142
|
+
if (count === 0)
|
|
143
|
+
return null;
|
|
144
|
+
switch (fn) {
|
|
145
|
+
case 'sum':
|
|
146
|
+
return sum;
|
|
147
|
+
case 'avg':
|
|
148
|
+
return sum / count;
|
|
149
|
+
case 'min':
|
|
150
|
+
return min;
|
|
151
|
+
case 'max':
|
|
152
|
+
return max;
|
|
153
|
+
default:
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Compute aggregates for every column that declared one → `{ fieldName: { fn, value } }`. */
|
|
158
|
+
function computeAggregates(items, columns) {
|
|
159
|
+
const out = {};
|
|
160
|
+
for (const col of columns) {
|
|
161
|
+
if (col.aggregate) {
|
|
162
|
+
out[col.fieldName] = { fn: col.aggregate, value: computeAggregate(items, col.fieldName, col.aggregate) };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Per-group aggregates over a contiguous, group-ordered item array. `ranges` are
|
|
169
|
+
* `{ key, startIndex, count }` (the buckets from `buildGroups`); each group's
|
|
170
|
+
* rows are `orderedItems.slice(startIndex, startIndex + count)`. Returns
|
|
171
|
+
* `{ [groupKey]: { [fieldName]: { fn, value } } }`. Pure (no Fluent types) so it
|
|
172
|
+
* stays in framework-agnostic core and is unit-testable.
|
|
173
|
+
*/
|
|
174
|
+
function computeGroupAggregates(orderedItems, ranges, columns) {
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const r of ranges) {
|
|
177
|
+
out[r.key] = computeAggregates(orderedItems.slice(r.startIndex, r.startIndex + r.count), columns);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Framework-agnostic filter model + predicate engine. No React / no Fluent
|
|
184
|
+
* imports (enforced by the package ESLint guard on src/core/**).
|
|
185
|
+
*
|
|
186
|
+
* The operator vocabulary uses FetchXML names (`eq`/`like`/`begins-with`/…) so a
|
|
187
|
+
* consumer can map a `GridFilterModel` onto a server-side FetchXML / OData query
|
|
188
|
+
* (e.g. `@dataverse-kit/runtime`) 1:1 — grid-kit applies it client-side here.
|
|
189
|
+
*/
|
|
190
|
+
const NO_VALUE_OPS = new Set(['null', 'not-null']);
|
|
191
|
+
const MULTI_VALUE_OPS = new Set(['in', 'not-in']);
|
|
192
|
+
/** Whether `operator` needs an operand value (false for `null`/`not-null`). */
|
|
193
|
+
function operatorRequiresValue(operator) {
|
|
194
|
+
return !NO_VALUE_OPS.has(operator);
|
|
195
|
+
}
|
|
196
|
+
/** Whether `operator` takes a list of values (`in`/`not-in`). */
|
|
197
|
+
function operatorIsMultiValue(operator) {
|
|
198
|
+
return MULTI_VALUE_OPS.has(operator);
|
|
199
|
+
}
|
|
200
|
+
/** A condition is applied only when fully specified (gates incomplete rows out). */
|
|
201
|
+
function isConditionComplete(c) {
|
|
202
|
+
if (!c.fieldName || !c.operator)
|
|
203
|
+
return false;
|
|
204
|
+
if (!operatorRequiresValue(c.operator))
|
|
205
|
+
return true;
|
|
206
|
+
if (operatorIsMultiValue(c.operator))
|
|
207
|
+
return Array.isArray(c.values) && c.values.length > 0;
|
|
208
|
+
return c.value !== undefined && c.value !== null && c.value !== '';
|
|
209
|
+
}
|
|
210
|
+
const isEmpty = (v) => v === null || v === undefined || v === '';
|
|
211
|
+
const asText = (v) => toDisplayString(v).toLowerCase();
|
|
212
|
+
/**
|
|
213
|
+
* Compare raw vs operand numerically, else by date. `undefined` ⇒ not comparable.
|
|
214
|
+
* Number-first is deliberate: a bare-year operand like `"2024"` compares as the
|
|
215
|
+
* number 2024 (the date editor always emits a full `YYYY-MM-DD`, never a bare year).
|
|
216
|
+
*/
|
|
217
|
+
function compare(raw, operand) {
|
|
218
|
+
const a = toNumber(raw);
|
|
219
|
+
const b = toNumber(operand);
|
|
220
|
+
if (a !== null && b !== null)
|
|
221
|
+
return a === b ? 0 : a < b ? -1 : 1;
|
|
222
|
+
const da = toDate(raw);
|
|
223
|
+
const db = toDate(operand);
|
|
224
|
+
if (da && db) {
|
|
225
|
+
// Compare at UTC calendar-day granularity — the filter UI picks a day, not an
|
|
226
|
+
// instant — so a date-only operand matches a stored datetime on the same day
|
|
227
|
+
// and the result is timezone-stable (no off-by-one across the date line).
|
|
228
|
+
const ta = Date.UTC(da.getUTCFullYear(), da.getUTCMonth(), da.getUTCDate());
|
|
229
|
+
const tb = Date.UTC(db.getUTCFullYear(), db.getUTCMonth(), db.getUTCDate());
|
|
230
|
+
return ta === tb ? 0 : ta < tb ? -1 : 1;
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
/** Loose equality: numeric when both coerce to numbers, else case-insensitive text. */
|
|
235
|
+
function looseEq(raw, operand) {
|
|
236
|
+
const a = toNumber(raw);
|
|
237
|
+
const b = toNumber(operand);
|
|
238
|
+
if (a !== null && b !== null)
|
|
239
|
+
return a === b;
|
|
240
|
+
return asText(raw) === operand.trim().toLowerCase();
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Equality used by `eq`/`ne`/`in`/`not-in`. Boolean operands (`'true'`/`'false'`
|
|
244
|
+
* from the Yes/No editor) compare via `toBool`, so a row stored as a JS boolean,
|
|
245
|
+
* `1`/`0`, or `'1'`/`'0'` (a Dataverse bit field) all match. Otherwise: text/numeric
|
|
246
|
+
* loose-equality, then a date/number `compare` so a `Date` (or date-string) still
|
|
247
|
+
* matches by calendar day — without this, `eq` on a date column never matched.
|
|
248
|
+
*/
|
|
249
|
+
function valuesEqual(raw, operand) {
|
|
250
|
+
if (operand === 'true' || operand === 'false')
|
|
251
|
+
return toBool(raw) === (operand === 'true');
|
|
252
|
+
if (looseEq(raw, operand))
|
|
253
|
+
return true;
|
|
254
|
+
return compare(raw, operand) === 0;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Evaluate one condition against a raw field value. Equality/relational compares
|
|
258
|
+
* are coercion-based: when both sides parse as numbers they compare numerically
|
|
259
|
+
* (so a numeric-looking text id like `'007'` equals `'7'`), dates compare by day,
|
|
260
|
+
* else case-insensitive text. Assumes a complete condition (see
|
|
261
|
+
* `isConditionComplete`) — `applyFilters` drops incomplete ones first; calling this
|
|
262
|
+
* directly on e.g. a `not-in` with no `values` returns `true` (matches everything).
|
|
263
|
+
*/
|
|
264
|
+
function evalCondition(raw, c) {
|
|
265
|
+
const operand = c.value ?? '';
|
|
266
|
+
switch (c.operator) {
|
|
267
|
+
case 'null':
|
|
268
|
+
return isEmpty(raw);
|
|
269
|
+
case 'not-null':
|
|
270
|
+
return !isEmpty(raw);
|
|
271
|
+
case 'eq':
|
|
272
|
+
return valuesEqual(raw, operand);
|
|
273
|
+
case 'ne':
|
|
274
|
+
return !valuesEqual(raw, operand);
|
|
275
|
+
case 'like':
|
|
276
|
+
return asText(raw).includes(operand.toLowerCase());
|
|
277
|
+
case 'not-like':
|
|
278
|
+
return !asText(raw).includes(operand.toLowerCase());
|
|
279
|
+
case 'begins-with':
|
|
280
|
+
return asText(raw).startsWith(operand.toLowerCase());
|
|
281
|
+
case 'ends-with':
|
|
282
|
+
return asText(raw).endsWith(operand.toLowerCase());
|
|
283
|
+
case 'in':
|
|
284
|
+
return (c.values ?? []).some((v) => valuesEqual(raw, v));
|
|
285
|
+
case 'not-in':
|
|
286
|
+
return !(c.values ?? []).some((v) => valuesEqual(raw, v));
|
|
287
|
+
case 'gt': {
|
|
288
|
+
const r = compare(raw, operand);
|
|
289
|
+
return r !== undefined && r > 0;
|
|
290
|
+
}
|
|
291
|
+
case 'ge':
|
|
292
|
+
case 'on-or-after': {
|
|
293
|
+
const r = compare(raw, operand);
|
|
294
|
+
return r !== undefined && r >= 0;
|
|
295
|
+
}
|
|
296
|
+
case 'lt': {
|
|
297
|
+
const r = compare(raw, operand);
|
|
298
|
+
return r !== undefined && r < 0;
|
|
299
|
+
}
|
|
300
|
+
case 'le':
|
|
301
|
+
case 'on-or-before': {
|
|
302
|
+
const r = compare(raw, operand);
|
|
303
|
+
return r !== undefined && r <= 0;
|
|
304
|
+
}
|
|
305
|
+
default:
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function defaultGetValue(item, fieldName) {
|
|
310
|
+
return item && typeof item === 'object' ? item[fieldName] : undefined;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Filter `items` by `model` (client-side, in-memory). Incomplete conditions are
|
|
314
|
+
* ignored; with no complete condition the input is returned unchanged. `match`
|
|
315
|
+
* `'all'` ⇒ every condition must pass (AND); `'any'` ⇒ at least one (OR).
|
|
316
|
+
*/
|
|
317
|
+
function applyFilters(items, model, getValue = defaultGetValue) {
|
|
318
|
+
if (!model)
|
|
319
|
+
return items;
|
|
320
|
+
const active = model.conditions.filter(isConditionComplete);
|
|
321
|
+
if (active.length === 0)
|
|
322
|
+
return items;
|
|
323
|
+
const matchAll = model.match !== 'any';
|
|
324
|
+
return items.filter((item) => matchAll
|
|
325
|
+
? active.every((c) => evalCondition(getValue(item, c.fieldName), c))
|
|
326
|
+
: active.some((c) => evalCondition(getValue(item, c.fieldName), c)));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** grid-kit renderer type → canonical library field type. */
|
|
330
|
+
const RENDERER_TO_FIELDTYPE = {
|
|
331
|
+
text: 'text',
|
|
332
|
+
link: 'link',
|
|
333
|
+
lookup: 'lookup',
|
|
334
|
+
optionset: 'optionset',
|
|
335
|
+
currency: 'currency',
|
|
336
|
+
number: 'number',
|
|
337
|
+
date: 'date',
|
|
338
|
+
datetime: 'datetime',
|
|
339
|
+
boolean: 'toggle',
|
|
340
|
+
toggle: 'toggle',
|
|
341
|
+
icon: 'icon',
|
|
342
|
+
progress: 'progressBar',
|
|
343
|
+
coloredCell: 'coloredCell',
|
|
344
|
+
rating: 'rating',
|
|
345
|
+
composite: 'composite',
|
|
346
|
+
// 'fileDrop' is grid-kit-native (FileDropCell) and never goes through the
|
|
347
|
+
// canonical-lib wrapper, so this entry is just to satisfy the exhaustive map.
|
|
348
|
+
fileDrop: 'text',
|
|
349
|
+
};
|
|
350
|
+
function rendererToFieldType(type) {
|
|
351
|
+
return RENDERER_TO_FIELDTYPE[type] ?? 'text';
|
|
352
|
+
}
|
|
353
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
354
|
+
/**
|
|
355
|
+
* Map a grid-kit column (renderer type + config bag) onto an IReusableColumn.
|
|
356
|
+
* `rendererConfig` keys accept both a short form (`max`, `color`) and the
|
|
357
|
+
* canonical IReusableColumn name (`progressMax`, `progressColor`).
|
|
358
|
+
*/
|
|
359
|
+
function toReusableColumn(column) {
|
|
360
|
+
const c = (column.rendererConfig ?? {});
|
|
361
|
+
const base = {
|
|
362
|
+
key: column.key,
|
|
363
|
+
name: column.name,
|
|
364
|
+
fieldName: column.fieldName,
|
|
365
|
+
minWidth: column.minWidth ?? 100,
|
|
366
|
+
maxWidth: column.maxWidth,
|
|
367
|
+
isResizable: column.isResizable ?? true,
|
|
368
|
+
fieldType: rendererToFieldType(column.rendererType),
|
|
369
|
+
editable: !!column.editorType && column.editorType !== 'none' && !column.isLocked,
|
|
370
|
+
onLinkClick: column.onLinkClick,
|
|
371
|
+
validate: column.validate,
|
|
372
|
+
};
|
|
373
|
+
switch (column.rendererType) {
|
|
374
|
+
case 'currency':
|
|
375
|
+
base.currencyCode = c.currencyCode;
|
|
376
|
+
base.locale = c.locale;
|
|
377
|
+
break;
|
|
378
|
+
case 'number':
|
|
379
|
+
base.locale = c.locale;
|
|
380
|
+
break;
|
|
381
|
+
case 'progress':
|
|
382
|
+
base.progressMax = c.max ?? c.progressMax;
|
|
383
|
+
base.progressColor = c.color ?? c.progressColor;
|
|
384
|
+
base.showProgressLabel = c.showLabel ?? c.showProgressLabel;
|
|
385
|
+
break;
|
|
386
|
+
case 'coloredCell':
|
|
387
|
+
base.colorMap = c.colorMap;
|
|
388
|
+
base.getBackgroundColor = c.getBackgroundColor;
|
|
389
|
+
break;
|
|
390
|
+
case 'optionset':
|
|
391
|
+
base.optionSetOptions = c.options ?? c.optionSetOptions;
|
|
392
|
+
base.colorMap = c.colorMap;
|
|
393
|
+
base.optionSetMultiSelect = c.multiSelect ?? c.optionSetMultiSelect;
|
|
394
|
+
break;
|
|
395
|
+
case 'rating':
|
|
396
|
+
base.ratingMax = c.max ?? c.ratingMax;
|
|
397
|
+
base.ratingColor = c.color ?? c.ratingColor;
|
|
398
|
+
base.ratingAllowHalf = c.allowHalf ?? c.ratingAllowHalf;
|
|
399
|
+
base.ratingStyle = c.style ?? c.ratingStyle;
|
|
400
|
+
base.ratingFaceCount = c.faceCount ?? c.ratingFaceCount;
|
|
401
|
+
base.ratingUseSentimentColor = c.useSentimentColor ?? c.ratingUseSentimentColor;
|
|
402
|
+
break;
|
|
403
|
+
case 'composite':
|
|
404
|
+
base.compositeSlots = c.slots ?? c.compositeSlots;
|
|
405
|
+
base.compositeLayout = c.layout ?? c.compositeLayout;
|
|
406
|
+
base.compositeGap = c.gap ?? c.compositeGap;
|
|
407
|
+
break;
|
|
408
|
+
case 'icon':
|
|
409
|
+
base.iconName = c.iconName;
|
|
410
|
+
base.iconTitle = c.iconTitle;
|
|
411
|
+
base.onIconClick = c.onIconClick ?? column.onLinkClick;
|
|
412
|
+
break;
|
|
413
|
+
case 'toggle':
|
|
414
|
+
case 'boolean':
|
|
415
|
+
base.toggleOnText = c.onText ?? c.toggleOnText;
|
|
416
|
+
base.toggleOffText = c.offText ?? c.toggleOffText;
|
|
417
|
+
base.onToggleChange = c.onToggleChange;
|
|
418
|
+
break;
|
|
419
|
+
case 'lookup':
|
|
420
|
+
base.lookupDisplayField = c.displayField ?? c.lookupDisplayField;
|
|
421
|
+
base.lookupEntitySetName = c.entitySetName ?? c.lookupEntitySetName;
|
|
422
|
+
base.lookupSearchField = c.searchField ?? c.lookupSearchField;
|
|
423
|
+
base.lookupTargetEntity = c.targetEntity ?? c.lookupTargetEntity;
|
|
424
|
+
base.lookupPrimaryIdAttribute = c.primaryIdAttribute ?? c.lookupPrimaryIdAttribute;
|
|
425
|
+
base.lookupFilter = c.filter ?? c.lookupFilter;
|
|
426
|
+
base.lookupSelectFields = c.selectFields ?? c.lookupSelectFields;
|
|
427
|
+
base.lookupSearch = c.searchLookup ?? c.lookupSearch;
|
|
428
|
+
break;
|
|
429
|
+
case 'link':
|
|
430
|
+
base.openInNewTab = c.openInNewTab;
|
|
431
|
+
base.isRecordLink = c.isRecordLink;
|
|
432
|
+
base.recordEntityLogicalName = c.recordEntityLogicalName;
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
if (c.formatValue && !base.formatValue)
|
|
436
|
+
base.formatValue = c.formatValue;
|
|
437
|
+
return base;
|
|
438
|
+
}
|
|
439
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
440
|
+
|
|
441
|
+
const noop = () => undefined;
|
|
442
|
+
/** Ensure the row exposes a `.key` (some canonical editable cells require it). */
|
|
443
|
+
function ensureKey(item, key) {
|
|
444
|
+
if (item && typeof item === 'object' && item.key !== undefined)
|
|
445
|
+
return item;
|
|
446
|
+
return { ...item, key };
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Bridge the display value into the canonical renderer's `formatValue` path so
|
|
450
|
+
* read cells honor the right label instead of re-deriving from the raw code.
|
|
451
|
+
* Precedence: user-provided formatValue > host-supplied formatted value
|
|
452
|
+
* (Dataverse optionset/lookup label) > optionset value→label map > the
|
|
453
|
+
* computed fallback for numeric/date types (which also fixes numeric-string
|
|
454
|
+
* values that the canonical Currency/Number cells would otherwise render as '-').
|
|
455
|
+
* Mutates the freshly-mapped column (a new object per render — safe).
|
|
456
|
+
*/
|
|
457
|
+
function applyDisplayValue(column, props) {
|
|
458
|
+
if (column.formatValue)
|
|
459
|
+
return; // explicit user formatter wins
|
|
460
|
+
if (props.suppliedFormattedValue) {
|
|
461
|
+
const s = props.suppliedFormattedValue;
|
|
462
|
+
column.formatValue = () => s;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
switch (column.fieldType) {
|
|
466
|
+
case 'optionset': {
|
|
467
|
+
const opts = column.optionSetOptions;
|
|
468
|
+
if (opts && opts.length) {
|
|
469
|
+
column.formatValue = (v) => {
|
|
470
|
+
const o = opts.find((opt) => String(opt.value) === String(v));
|
|
471
|
+
return o ? o.label : v !== null && v !== undefined && v !== '' ? String(v) : '-';
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
case 'currency':
|
|
477
|
+
case 'number':
|
|
478
|
+
case 'date':
|
|
479
|
+
case 'datetime': {
|
|
480
|
+
const fv = props.formattedValue;
|
|
481
|
+
column.formatValue = () => fv || '-';
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
default:
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/** Read-only wrapper: maps GridCellProps → the canonical CellRendererProps. */
|
|
489
|
+
function makeReadWrapper(Comp, displayName) {
|
|
490
|
+
const Wrapped = (props) => {
|
|
491
|
+
const column = toReusableColumn(props.column);
|
|
492
|
+
applyDisplayValue(column, props);
|
|
493
|
+
// Editable toggles render an interactive ToggleCell (no editable variant
|
|
494
|
+
// exists); wire its change back to the grid's onValueChange so edits persist.
|
|
495
|
+
if (column.editable && !column.onToggleChange && props.onValueChange) {
|
|
496
|
+
const onVC = props.onValueChange;
|
|
497
|
+
const original = props.value;
|
|
498
|
+
column.onToggleChange = (key, fieldName, v) => onVC(key, fieldName, v, original);
|
|
499
|
+
}
|
|
500
|
+
const cellProps = {
|
|
501
|
+
item: props.item,
|
|
502
|
+
column,
|
|
503
|
+
value: props.value,
|
|
504
|
+
itemKey: props.itemKey,
|
|
505
|
+
};
|
|
506
|
+
return jsx(Comp, { ...cellProps });
|
|
507
|
+
};
|
|
508
|
+
Wrapped.displayName = displayName;
|
|
509
|
+
return Wrapped;
|
|
510
|
+
}
|
|
511
|
+
/** Editable wrapper: threads edit-state onto the canonical editable cell props. */
|
|
512
|
+
function makeEditWrapper(Comp, displayName) {
|
|
513
|
+
const Wrapped = (props) => {
|
|
514
|
+
const column = toReusableColumn(props.column);
|
|
515
|
+
applyDisplayValue(column, props); // read-mode display of an editable cell
|
|
516
|
+
const item = ensureKey(props.item, props.itemKey);
|
|
517
|
+
const editProps = {
|
|
518
|
+
item,
|
|
519
|
+
column,
|
|
520
|
+
value: props.value,
|
|
521
|
+
itemKey: props.itemKey,
|
|
522
|
+
isEditMode: props.isEditing ?? false,
|
|
523
|
+
isDirty: props.isDirty ?? false,
|
|
524
|
+
editedValue: props.editedValue,
|
|
525
|
+
onValueChange: props.onValueChange ?? noop,
|
|
526
|
+
errorMessage: props.errorMessage,
|
|
527
|
+
onValidationError: props.onValidationError,
|
|
528
|
+
};
|
|
529
|
+
return jsx(Comp, { ...editProps });
|
|
530
|
+
};
|
|
531
|
+
Wrapped.displayName = displayName;
|
|
532
|
+
return Wrapped;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function normalizeExt(ext) {
|
|
536
|
+
const e = ext.toLowerCase();
|
|
537
|
+
return e.startsWith('.') ? e : `.${e}`;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Whether a file name matches an accept list of extensions (case-insensitive,
|
|
541
|
+
* leading dot optional). Empty/undefined accept = anything. Exported for reuse
|
|
542
|
+
* in custom drop targets.
|
|
543
|
+
*/
|
|
544
|
+
function isFileAccepted(name, accept) {
|
|
545
|
+
if (!accept || accept.length === 0)
|
|
546
|
+
return true;
|
|
547
|
+
const lower = name.toLowerCase();
|
|
548
|
+
return accept.some((ext) => lower.endsWith(normalizeExt(ext)));
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* HTML5 file-drop behavior for a grid cell or row. Tracks drag depth (so the
|
|
552
|
+
* highlight doesn't flicker over child elements), validates the dropped files
|
|
553
|
+
* against `accept`, and exposes the drag-over state + the handler props to spread
|
|
554
|
+
* onto a wrapper element. Pure HTML5 — no drag library.
|
|
555
|
+
*/
|
|
556
|
+
function useFileDrop(opts) {
|
|
557
|
+
const { onDrop, accept, multiple = false, disabled, onReject } = opts;
|
|
558
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
559
|
+
const depth = useRef(0);
|
|
560
|
+
const onDragEnter = useCallback((e) => {
|
|
561
|
+
if (disabled)
|
|
562
|
+
return;
|
|
563
|
+
e.preventDefault();
|
|
564
|
+
e.stopPropagation();
|
|
565
|
+
depth.current += 1;
|
|
566
|
+
setIsDragOver(true);
|
|
567
|
+
}, [disabled]);
|
|
568
|
+
const onDragOver = useCallback((e) => {
|
|
569
|
+
if (disabled)
|
|
570
|
+
return;
|
|
571
|
+
e.preventDefault();
|
|
572
|
+
e.stopPropagation();
|
|
573
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
574
|
+
}, [disabled]);
|
|
575
|
+
const onDragLeave = useCallback((e) => {
|
|
576
|
+
if (disabled)
|
|
577
|
+
return;
|
|
578
|
+
e.preventDefault();
|
|
579
|
+
e.stopPropagation();
|
|
580
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
581
|
+
if (depth.current === 0)
|
|
582
|
+
setIsDragOver(false);
|
|
583
|
+
}, [disabled]);
|
|
584
|
+
const onDropHandler = useCallback((e) => {
|
|
585
|
+
if (disabled)
|
|
586
|
+
return;
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
e.stopPropagation();
|
|
589
|
+
depth.current = 0;
|
|
590
|
+
setIsDragOver(false);
|
|
591
|
+
const all = Array.from(e.dataTransfer.files ?? []);
|
|
592
|
+
if (all.length === 0)
|
|
593
|
+
return;
|
|
594
|
+
const valid = all.filter((f) => isFileAccepted(f.name, accept));
|
|
595
|
+
if (valid.length === 0) {
|
|
596
|
+
onReject?.(accept && accept.length ? `Only ${accept.join(', ')} files are allowed.` : 'File not allowed.');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
onDrop(multiple ? valid : [valid[0]]);
|
|
600
|
+
}, [disabled, accept, multiple, onDrop, onReject]);
|
|
601
|
+
return {
|
|
602
|
+
isDragOver,
|
|
603
|
+
dragProps: { onDragEnter, onDragOver, onDragLeave, onDrop: onDropHandler },
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Wraps any content as an HTML5 file-drop target with a drag-over highlight.
|
|
609
|
+
* Use inside a custom cell renderer (or directly) to accept files dropped from
|
|
610
|
+
* the OS onto a cell. The `fileDrop` registry cell type uses this internally.
|
|
611
|
+
*/
|
|
612
|
+
const DroppableCell = ({ children, className, style, variant = 'highlight', ...opts }) => {
|
|
613
|
+
const { isDragOver, dragProps } = useFileDrop(opts);
|
|
614
|
+
const active = isDragOver && !opts.disabled;
|
|
615
|
+
return (jsx("div", { ...dragProps, className: className, style: {
|
|
616
|
+
display: 'flex',
|
|
617
|
+
alignItems: 'center',
|
|
618
|
+
height: '100%',
|
|
619
|
+
width: '100%',
|
|
620
|
+
borderRadius: 4,
|
|
621
|
+
outline: active && variant === 'highlight' ? '2px solid #0078d4' : '2px solid transparent',
|
|
622
|
+
background: active ? 'rgba(0,120,212,0.08)' : 'transparent',
|
|
623
|
+
boxShadow: active && variant === 'highlight' ? '0 0 6px 1px rgba(0,120,212,0.4)' : 'none',
|
|
624
|
+
transition: 'background 80ms ease-out, box-shadow 80ms ease-out, outline-color 80ms ease-out',
|
|
625
|
+
...style,
|
|
626
|
+
}, children: children }));
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Grid-kit-native cell renderer for `rendererType: 'fileDrop'`. Renders the cell
|
|
631
|
+
* as a droppable target; on drop it calls `rendererConfig.onDrop(files, item,
|
|
632
|
+
* fieldName)`. Unlike the other built-ins it does NOT wrap a canonical renderer.
|
|
633
|
+
*/
|
|
634
|
+
const FileDropCell = (props) => {
|
|
635
|
+
const cfg = (props.column.rendererConfig ?? {});
|
|
636
|
+
const field = props.column.fieldName;
|
|
637
|
+
// Explicit label wins; else show the cell's value; else the drop hint.
|
|
638
|
+
// (formattedValue is '' — not undefined — when the value is empty, so use `||`.)
|
|
639
|
+
const text = cfg.label ?? (props.formattedValue || 'Drop file');
|
|
640
|
+
return (jsx(DroppableCell, { accept: cfg.accept, multiple: cfg.multiple, onDrop: (files) => cfg.onDrop?.(files, props.item, field), onReject: cfg.onReject ? (reason) => cfg.onReject(reason, props.item, field) : undefined, style: { justifyContent: 'center', cursor: 'copy', fontSize: 13, color: '#605e5c' }, children: jsx("span", { children: text }) }));
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The cell-renderer registry — the single source of truth that both grid hosts
|
|
645
|
+
* (DetailsList adapter + GridCustomizer overrides) consume. Maps each
|
|
646
|
+
* `CellRendererType` to a read (and optional edit) wrapper over the canonical
|
|
647
|
+
* @khester/dynamics-cell-renderers components.
|
|
648
|
+
*/
|
|
649
|
+
function buildDefaults() {
|
|
650
|
+
const m = new Map();
|
|
651
|
+
m.set('text', {
|
|
652
|
+
read: makeReadWrapper(TextCell, 'GridKit(text)'),
|
|
653
|
+
edit: makeEditWrapper(EditableTextCell, 'GridKit(edit:text)'),
|
|
654
|
+
});
|
|
655
|
+
m.set('link', { read: makeReadWrapper(LinkCell, 'GridKit(link)') });
|
|
656
|
+
// Editable lookup is fetch-free: the consumer supplies `searchLookup(term)` on
|
|
657
|
+
// the column's rendererConfig (mapped to `column.lookupSearch`), so the cell
|
|
658
|
+
// never needs an apiService. A column opts in with editorType:'lookup'.
|
|
659
|
+
m.set('lookup', {
|
|
660
|
+
read: makeReadWrapper(LookupCell, 'GridKit(lookup)'),
|
|
661
|
+
edit: makeEditWrapper(EditableLookupCell, 'GridKit(edit:lookup)'),
|
|
662
|
+
});
|
|
663
|
+
m.set('optionset', {
|
|
664
|
+
read: makeReadWrapper(OptionSetCell, 'GridKit(optionset)'),
|
|
665
|
+
edit: makeEditWrapper(EditableOptionSetCell, 'GridKit(edit:optionset)'),
|
|
666
|
+
});
|
|
667
|
+
m.set('currency', {
|
|
668
|
+
read: makeReadWrapper(CurrencyCell, 'GridKit(currency)'),
|
|
669
|
+
edit: makeEditWrapper(EditableNumberCell, 'GridKit(edit:currency)'),
|
|
670
|
+
});
|
|
671
|
+
m.set('number', {
|
|
672
|
+
read: makeReadWrapper(NumberCell, 'GridKit(number)'),
|
|
673
|
+
edit: makeEditWrapper(EditableNumberCell, 'GridKit(edit:number)'),
|
|
674
|
+
});
|
|
675
|
+
m.set('date', {
|
|
676
|
+
read: makeReadWrapper(DateCell, 'GridKit(date)'),
|
|
677
|
+
edit: makeEditWrapper(EditableDateCell, 'GridKit(edit:date)'),
|
|
678
|
+
});
|
|
679
|
+
m.set('datetime', {
|
|
680
|
+
read: makeReadWrapper(DateCell, 'GridKit(datetime)'),
|
|
681
|
+
edit: makeEditWrapper(EditableDateCell, 'GridKit(edit:datetime)'),
|
|
682
|
+
});
|
|
683
|
+
m.set('boolean', { read: makeReadWrapper(ToggleCell, 'GridKit(boolean)') });
|
|
684
|
+
m.set('toggle', { read: makeReadWrapper(ToggleCell, 'GridKit(toggle)') });
|
|
685
|
+
m.set('icon', { read: makeReadWrapper(IconCell, 'GridKit(icon)') });
|
|
686
|
+
m.set('progress', { read: makeReadWrapper(ProgressBarCell, 'GridKit(progress)') });
|
|
687
|
+
m.set('coloredCell', { read: makeReadWrapper(ColoredCell, 'GridKit(coloredCell)') });
|
|
688
|
+
// Rating is click-to-set editable (stars or sentiment emoji). Opt in with
|
|
689
|
+
// editorType:'number'; rendererConfig.style:'emoji' switches to faces.
|
|
690
|
+
m.set('rating', {
|
|
691
|
+
read: makeReadWrapper(RatingCell, 'GridKit(rating)'),
|
|
692
|
+
edit: makeEditWrapper(EditableRatingCell, 'GridKit(edit:rating)'),
|
|
693
|
+
});
|
|
694
|
+
m.set('composite', { read: makeReadWrapper(CompositeCell, 'GridKit(composite)') });
|
|
695
|
+
// grid-kit-native (not a canonical-lib wrapper): a per-cell file-drop target.
|
|
696
|
+
m.set('fileDrop', { read: FileDropCell });
|
|
697
|
+
return m;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Create a cell-renderer registry with all built-ins, optionally overridden.
|
|
701
|
+
* Consumers can also call `registry.register(type, { read, edit })` later to
|
|
702
|
+
* override a built-in or add a brand-new cell type.
|
|
703
|
+
*/
|
|
704
|
+
function createCellRegistry(overrides) {
|
|
705
|
+
const map = buildDefaults();
|
|
706
|
+
if (overrides) {
|
|
707
|
+
for (const [key, reg] of Object.entries(overrides))
|
|
708
|
+
map.set(key, reg);
|
|
709
|
+
}
|
|
710
|
+
const registry = {
|
|
711
|
+
get: (type) => map.get(type),
|
|
712
|
+
register: (type, reg) => {
|
|
713
|
+
map.set(type, reg);
|
|
714
|
+
},
|
|
715
|
+
resolve: (column, editable) => {
|
|
716
|
+
const reg = map.get(column.rendererType);
|
|
717
|
+
if (!reg) {
|
|
718
|
+
// Unknown renderer type → safe text fallback (no silent blank cells).
|
|
719
|
+
// Read 'text' live so a consumer's register('text', …) override is honored.
|
|
720
|
+
// eslint-disable-next-line no-console
|
|
721
|
+
if (typeof console !== 'undefined') {
|
|
722
|
+
console.warn(`[grid-kit] No renderer registered for "${column.rendererType}"; using text.`);
|
|
723
|
+
}
|
|
724
|
+
return map.get('text').read;
|
|
725
|
+
}
|
|
726
|
+
const canEdit = editable &&
|
|
727
|
+
!!column.editorType &&
|
|
728
|
+
column.editorType !== 'none' &&
|
|
729
|
+
!column.isLocked &&
|
|
730
|
+
!!reg.edit;
|
|
731
|
+
return (canEdit ? reg.edit : reg.read);
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
return registry;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* `Xrm.Navigation.navigateTo` wrapper — open a record form, web resource, or
|
|
739
|
+
* custom page from a grid. Loose-typed (NO `@types/xrm` dependency). On localhost
|
|
740
|
+
* or when Xrm is unavailable (Storybook / tests / non-model-driven host) it logs a
|
|
741
|
+
* no-op and resolves, so consumers can wire navigation unconditionally.
|
|
742
|
+
*
|
|
743
|
+
* Lives OUTSIDE src/core/ on purpose: it touches `window`/`Xrm` (a side-effectful
|
|
744
|
+
* host concern), whereas core/ is v9-portable pure logic.
|
|
745
|
+
*/
|
|
746
|
+
function getXrm() {
|
|
747
|
+
if (typeof window === 'undefined')
|
|
748
|
+
return undefined;
|
|
749
|
+
return window.Xrm;
|
|
750
|
+
}
|
|
751
|
+
/** Mirror the ServiceFactory localhost check used across the kit. */
|
|
752
|
+
function isLocalhost() {
|
|
753
|
+
if (typeof window === 'undefined' || !window.location)
|
|
754
|
+
return false;
|
|
755
|
+
const h = window.location.hostname;
|
|
756
|
+
return h === 'localhost' || h === '127.0.0.1';
|
|
757
|
+
}
|
|
758
|
+
/** Map a `NavTarget` → an Xrm `pageInput` object. */
|
|
759
|
+
function toPageInput(target) {
|
|
760
|
+
switch (target.pageType) {
|
|
761
|
+
case 'entityrecord':
|
|
762
|
+
return { pageType: 'entityrecord', entityName: target.entityName, entityId: target.entityId };
|
|
763
|
+
case 'webresource':
|
|
764
|
+
return { pageType: 'webresource', webresourceName: target.webresourceName, data: target.data };
|
|
765
|
+
case 'custom':
|
|
766
|
+
return { pageType: 'custom', name: target.name, entityName: target.entityName, recordId: target.entityId };
|
|
767
|
+
default:
|
|
768
|
+
return {};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Navigate to `target` via `Xrm.Navigation.navigateTo`. No-ops (console.log) on
|
|
773
|
+
* localhost or when Xrm is unavailable. Always resolves (never throws) so a
|
|
774
|
+
* cell-click handler can call it without a try/catch.
|
|
775
|
+
*
|
|
776
|
+
* The options the platform receives are the explicit `navigationOptions` arg if
|
|
777
|
+
* given, else `target.navigationOptions` — so callers that only have a
|
|
778
|
+
* `NavTarget` (cell-click synthesis, the "Open" command) still get dialog/size
|
|
779
|
+
* control by attaching options to the target.
|
|
780
|
+
*/
|
|
781
|
+
async function navigateToTarget(target, navigationOptions) {
|
|
782
|
+
const xrm = getXrm();
|
|
783
|
+
const options = navigationOptions ?? target.navigationOptions;
|
|
784
|
+
if (isLocalhost() || !xrm?.Navigation?.navigateTo) {
|
|
785
|
+
// eslint-disable-next-line no-console
|
|
786
|
+
if (typeof console !== 'undefined') {
|
|
787
|
+
console.log('[grid-kit] navigateTo (no-op — localhost or no Xrm):', target, options);
|
|
788
|
+
}
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
await xrm.Navigation.navigateTo(toPageInput(target), options);
|
|
793
|
+
}
|
|
794
|
+
catch (err) {
|
|
795
|
+
// eslint-disable-next-line no-console
|
|
796
|
+
if (typeof console !== 'undefined')
|
|
797
|
+
console.error('[grid-kit] navigateTo failed:', err);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
802
|
+
/** Read a row field value. */
|
|
803
|
+
function getFieldValue(item, fieldName) {
|
|
804
|
+
return item && typeof item === 'object' ? item[fieldName] : undefined;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Host-supplied formatted value (Dataverse `@OData…FormattedValue` sibling key),
|
|
808
|
+
* if present. Adapters prefer this over recomputing.
|
|
809
|
+
*/
|
|
810
|
+
function getSuppliedFormattedValue(item, fieldName) {
|
|
811
|
+
if (!item || typeof item !== 'object')
|
|
812
|
+
return undefined;
|
|
813
|
+
const fv = item[`${fieldName}@OData.Community.Display.V1.FormattedValue`];
|
|
814
|
+
return fv !== undefined && fv !== null ? String(fv) : undefined;
|
|
815
|
+
}
|
|
816
|
+
// Stable synthetic keys for rows that lack a `key` — keeps edit-state per-row
|
|
817
|
+
// instead of collapsing every keyless row to '' (which caused cross-row edit
|
|
818
|
+
// bleed). Same object identity ⇒ same key across renders.
|
|
819
|
+
const SYNTH_KEYS = new WeakMap();
|
|
820
|
+
let synthCounter = 0;
|
|
821
|
+
let warnedMissingKey = false;
|
|
822
|
+
function defaultGetKey(item) {
|
|
823
|
+
const k = item?.key;
|
|
824
|
+
if (k !== undefined && k !== null && k !== '')
|
|
825
|
+
return String(k);
|
|
826
|
+
if (item && typeof item === 'object') {
|
|
827
|
+
let s = SYNTH_KEYS.get(item);
|
|
828
|
+
if (!s) {
|
|
829
|
+
s = '__gridkit_row_' + ++synthCounter;
|
|
830
|
+
SYNTH_KEYS.set(item, s);
|
|
831
|
+
if (!warnedMissingKey && typeof console !== 'undefined') {
|
|
832
|
+
warnedMissingKey = true;
|
|
833
|
+
console.warn('[grid-kit] Rows are missing a `key`; using synthetic keys. Pass `getKey` or give each row a stable `key` for reliable selection/editing.');
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return s;
|
|
837
|
+
}
|
|
838
|
+
return String(k ?? '');
|
|
839
|
+
}
|
|
840
|
+
function rendererFormatConfig(column) {
|
|
841
|
+
const c = (column.rendererConfig ?? {});
|
|
842
|
+
return { currencyCode: c.currencyCode, locale: c.locale };
|
|
843
|
+
}
|
|
844
|
+
/** Build the normalized cell contract for one (column, row) pair. */
|
|
845
|
+
function buildCellProps(column, item, ctx) {
|
|
846
|
+
const getKey = ctx.getKey ?? defaultGetKey;
|
|
847
|
+
const itemKey = getKey(item);
|
|
848
|
+
const value = getFieldValue(item, column.fieldName);
|
|
849
|
+
// Navigation: for link/lookup cells with no explicit onLinkClick, synthesize one
|
|
850
|
+
// from the grid-level `navigateTo` resolver. Done here (the seam both host
|
|
851
|
+
// adapters share) so DetailsList + GridCustomizer get nav parity for free.
|
|
852
|
+
let effectiveColumn = column;
|
|
853
|
+
if (ctx.navigateTo &&
|
|
854
|
+
!column.onLinkClick &&
|
|
855
|
+
(column.rendererType === 'link' || column.rendererType === 'lookup')) {
|
|
856
|
+
const resolve = ctx.navigateTo;
|
|
857
|
+
effectiveColumn = {
|
|
858
|
+
...column,
|
|
859
|
+
onLinkClick: (it) => {
|
|
860
|
+
const target = resolve(it);
|
|
861
|
+
if (target)
|
|
862
|
+
void navigateToTarget(target);
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
const supplied = ctx.formattedValueAccessor
|
|
867
|
+
? ctx.formattedValueAccessor(item, column)
|
|
868
|
+
: getSuppliedFormattedValue(item, column.fieldName);
|
|
869
|
+
const formattedValue = supplied ?? computeFormattedValue(value, column.rendererType, rendererFormatConfig(column));
|
|
870
|
+
return {
|
|
871
|
+
value,
|
|
872
|
+
formattedValue,
|
|
873
|
+
suppliedFormattedValue: supplied,
|
|
874
|
+
item,
|
|
875
|
+
itemKey,
|
|
876
|
+
column: effectiveColumn,
|
|
877
|
+
isEditing: ctx.isEditing?.(itemKey, column.fieldName) ?? false,
|
|
878
|
+
isDirty: ctx.isDirty?.(itemKey, column.fieldName) ?? false,
|
|
879
|
+
editedValue: ctx.getEditedValue?.(itemKey, column.fieldName),
|
|
880
|
+
errorMessage: ctx.getError?.(itemKey, column.fieldName),
|
|
881
|
+
onValueChange: ctx.onValueChange,
|
|
882
|
+
onValidationError: ctx.onValidationError,
|
|
883
|
+
onEditStart: ctx.onEditStart ? () => ctx.onEditStart(itemKey, column.fieldName) : undefined,
|
|
884
|
+
onEditEnd: ctx.onEditEnd ? () => ctx.onEditEnd(itemKey, column.fieldName) : undefined,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
888
|
+
|
|
889
|
+
const FOCUSABLE = 'input, textarea, select, button, [tabindex]:not([tabindex="-1"])';
|
|
890
|
+
/** True when focus moved into a Fluent v8 portal (Dropdown/Calendar/Lookup
|
|
891
|
+
* option lists render in a Layer/Callout OUTSIDE the cell's DOM subtree). */
|
|
892
|
+
function isInFluentPortal(node) {
|
|
893
|
+
const el = node;
|
|
894
|
+
return !!el?.closest?.('.ms-Layer, .ms-Callout');
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Wraps an editable cell that is currently in edit mode. On mount (i.e. when the
|
|
898
|
+
* cell enters edit mode) it focuses the first focusable control, and it exits
|
|
899
|
+
* edit mode on Enter/Escape or on blur to outside — but NOT when focus moves into
|
|
900
|
+
* a Fluent portal (otherwise picking a Dropdown/Calendar/Lookup option would
|
|
901
|
+
* close the editor before the value commits).
|
|
902
|
+
*/
|
|
903
|
+
const EditCellWrapper = ({ onExit, children }) => {
|
|
904
|
+
const ref = useRef(null);
|
|
905
|
+
useEffect(() => {
|
|
906
|
+
ref.current?.querySelector(FOCUSABLE)?.focus();
|
|
907
|
+
}, []);
|
|
908
|
+
return (jsx("div", { ref: ref, style: { height: '100%' }, onKeyDown: (e) => {
|
|
909
|
+
if (e.key === 'Enter' || e.key === 'Escape')
|
|
910
|
+
onExit();
|
|
911
|
+
}, onBlur: (e) => {
|
|
912
|
+
const next = e.relatedTarget;
|
|
913
|
+
if (isInFluentPortal(next))
|
|
914
|
+
return; // selecting a portal option, not exiting
|
|
915
|
+
if (!e.currentTarget.contains(next))
|
|
916
|
+
onExit();
|
|
917
|
+
}, children: children }));
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Wraps a cell in a hover-triggered Fluent v8 `Callout` showing preview content
|
|
922
|
+
* (e.g. a lookup's related-record fields). Consumed by `toDetailsListColumns` for
|
|
923
|
+
* any `ColumnDef` that declares `calloutContent` — the host wiring that the
|
|
924
|
+
* `ColumnDef.calloutContent`/`dismissMode`/`timeoutDuration` contract was always
|
|
925
|
+
* meant to drive (it was previously declared-but-unconsumed).
|
|
926
|
+
*/
|
|
927
|
+
function CalloutCell({ children, renderContent, dismissMode = 'mouseleave', timeoutDuration = 200, }) {
|
|
928
|
+
const [open, setOpen] = useState(false);
|
|
929
|
+
const anchorRef = useRef(null);
|
|
930
|
+
const timer = useRef(null);
|
|
931
|
+
const clearTimer = useCallback(() => {
|
|
932
|
+
if (timer.current) {
|
|
933
|
+
clearTimeout(timer.current);
|
|
934
|
+
timer.current = null;
|
|
935
|
+
}
|
|
936
|
+
}, []);
|
|
937
|
+
// Clear any pending dismiss timer on unmount (cells unmount on scroll in a
|
|
938
|
+
// virtualized DetailsList) so a stale callback can't setState after unmount.
|
|
939
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
940
|
+
const onEnter = useCallback(() => {
|
|
941
|
+
clearTimer();
|
|
942
|
+
setOpen(true);
|
|
943
|
+
}, [clearTimer]);
|
|
944
|
+
const onLeave = useCallback(() => {
|
|
945
|
+
if (dismissMode === 'timeout') {
|
|
946
|
+
clearTimer();
|
|
947
|
+
timer.current = setTimeout(() => setOpen(false), timeoutDuration);
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
setOpen(false);
|
|
951
|
+
}
|
|
952
|
+
}, [clearTimer, dismissMode, timeoutDuration]);
|
|
953
|
+
// Keep the callout open while the pointer is over its content (timeout mode only).
|
|
954
|
+
const calloutHover = dismissMode === 'timeout' ? { onMouseEnter: clearTimer, onMouseLeave: onLeave } : {};
|
|
955
|
+
return (jsxs("span", { ref: anchorRef, onMouseEnter: onEnter, onMouseLeave: onLeave, style: { display: 'inline-block', width: '100%' }, children: [children, open && anchorRef.current && (jsx(Callout, { target: anchorRef.current, directionalHint: DirectionalHint.bottomLeftEdge, isBeakVisible: false, gapSpace: 0, onDismiss: () => setOpen(false), ...calloutHover, children: jsx("div", { style: { padding: 12, maxWidth: 320 }, children: renderContent() }) }))] }));
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Wrap a rendered cell in a hover-preview callout when the column declares `calloutContent`. */
|
|
959
|
+
function withCallout(column, item, rendered) {
|
|
960
|
+
if (!column.calloutContent)
|
|
961
|
+
return rendered;
|
|
962
|
+
return (jsx(CalloutCell, { renderContent: () => column.calloutContent(item), dismissMode: column.dismissMode, timeoutDuration: column.timeoutDuration, children: rendered }));
|
|
963
|
+
}
|
|
964
|
+
function toDetailsListColumns(columns, ctx) {
|
|
965
|
+
return columns.map((column) => {
|
|
966
|
+
const sortable = column.isSortable && !!ctx.onSortChange;
|
|
967
|
+
return {
|
|
968
|
+
key: column.key,
|
|
969
|
+
name: column.name,
|
|
970
|
+
fieldName: column.fieldName,
|
|
971
|
+
minWidth: column.minWidth ?? 100,
|
|
972
|
+
maxWidth: column.maxWidth,
|
|
973
|
+
isResizable: column.isResizable ?? true,
|
|
974
|
+
isSorted: ctx.sort?.fieldName === column.fieldName,
|
|
975
|
+
isSortedDescending: ctx.sort?.fieldName === column.fieldName && ctx.sort?.direction === 'desc',
|
|
976
|
+
columnActionsMode: sortable ? undefined : 0, // 0 = disabled when not sortable
|
|
977
|
+
onColumnClick: sortable
|
|
978
|
+
? () => {
|
|
979
|
+
const nextDir = ctx.sort?.fieldName === column.fieldName && ctx.sort?.direction === 'asc' ? 'desc' : 'asc';
|
|
980
|
+
ctx.onSortChange(column.fieldName, nextDir);
|
|
981
|
+
}
|
|
982
|
+
: undefined,
|
|
983
|
+
onRender: (item) => {
|
|
984
|
+
if (item === undefined || item === null)
|
|
985
|
+
return null;
|
|
986
|
+
// Escape hatch: fully custom render bypasses the registry.
|
|
987
|
+
if (column.onRender)
|
|
988
|
+
return column.onRender(item);
|
|
989
|
+
const props = buildCellProps(column, item, ctx);
|
|
990
|
+
const Renderer = ctx.registry.resolve(column, !!ctx.editable);
|
|
991
|
+
const cell = jsx(Renderer, { ...props });
|
|
992
|
+
// Per-cell click-to-edit affordance: editable columns enter edit mode on
|
|
993
|
+
// click (one cell at a time) and exit on Enter/Escape/blur. Only cell
|
|
994
|
+
// types that actually have an edit renderer get the affordance — toggle/
|
|
995
|
+
// boolean are already interactive in read mode and ignore edit state.
|
|
996
|
+
// With editTrigger 'always' (or non-editable columns) the cell renders plainly.
|
|
997
|
+
const isEditableCell = !!ctx.editable &&
|
|
998
|
+
!!column.editorType &&
|
|
999
|
+
column.editorType !== 'none' &&
|
|
1000
|
+
!column.isLocked &&
|
|
1001
|
+
!!ctx.registry.get(column.rendererType)?.edit &&
|
|
1002
|
+
(ctx.editTrigger ?? 'click') === 'click';
|
|
1003
|
+
// Resolve the cell to its rendered form (plain / editing / click-to-edit),
|
|
1004
|
+
// then apply the hover-preview callout once so it composes with any path.
|
|
1005
|
+
let rendered;
|
|
1006
|
+
if (!isEditableCell) {
|
|
1007
|
+
rendered = cell;
|
|
1008
|
+
}
|
|
1009
|
+
else if (props.isEditing) {
|
|
1010
|
+
rendered = jsx(EditCellWrapper, { onExit: () => props.onEditEnd?.(), children: cell });
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
rendered = (jsx("div", { role: "button", tabIndex: 0, title: "Click to edit", style: { height: '100%', cursor: 'text', display: 'flex', alignItems: 'center' }, onClick: () => props.onEditStart?.(), onKeyDown: (e) => {
|
|
1014
|
+
if (e.key === 'Enter' || e.key === 'F2')
|
|
1015
|
+
props.onEditStart?.();
|
|
1016
|
+
}, children: cell }));
|
|
1017
|
+
}
|
|
1018
|
+
return withCallout(column, item, rendered);
|
|
1019
|
+
},
|
|
1020
|
+
};
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function toGridCustomizerOverrides(columns, registry, options = {}) {
|
|
1025
|
+
const overrides = {};
|
|
1026
|
+
for (const column of columns) {
|
|
1027
|
+
const Override = (gc) => {
|
|
1028
|
+
const value = gc.value;
|
|
1029
|
+
// Trust the platform's formatted value; only fall back when absent.
|
|
1030
|
+
const formattedValue = gc.formattedValue ??
|
|
1031
|
+
computeFormattedValue(value, column.rendererType, {
|
|
1032
|
+
currencyCode: column.rendererConfig?.currencyCode,
|
|
1033
|
+
locale: column.rendererConfig?.locale,
|
|
1034
|
+
});
|
|
1035
|
+
const props = {
|
|
1036
|
+
value,
|
|
1037
|
+
formattedValue,
|
|
1038
|
+
suppliedFormattedValue: gc.formattedValue,
|
|
1039
|
+
item: gc.rowData,
|
|
1040
|
+
itemKey: gc.entityId,
|
|
1041
|
+
column,
|
|
1042
|
+
isEditing: gc.isEditing,
|
|
1043
|
+
// The platform GC host owns dirty/validation state, so these are not
|
|
1044
|
+
// available on this path (unlike the DetailsList host's useEditState).
|
|
1045
|
+
isDirty: undefined,
|
|
1046
|
+
editedValue: undefined,
|
|
1047
|
+
errorMessage: undefined,
|
|
1048
|
+
onValidationError: undefined,
|
|
1049
|
+
// The GC host's onChange takes only the new value, so fieldName +
|
|
1050
|
+
// originalValue from grid-kit's 4-arg signature are intentionally dropped.
|
|
1051
|
+
onValueChange: gc.onChange ? (_rk, _fn, v) => gc.onChange(v) : undefined,
|
|
1052
|
+
onEditStart: gc.onEditStart,
|
|
1053
|
+
onEditEnd: gc.onEditEnd,
|
|
1054
|
+
};
|
|
1055
|
+
const Renderer = registry.resolve(column, !!gc.isEditing);
|
|
1056
|
+
return jsx(Renderer, { ...props });
|
|
1057
|
+
};
|
|
1058
|
+
Override.displayName = `GcOverride(${column.fieldName})`;
|
|
1059
|
+
overrides[column.fieldName] = Override;
|
|
1060
|
+
if (options.keyByDataType) {
|
|
1061
|
+
overrides[column.rendererType] = overrides[column.rendererType] ?? Override;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return overrides;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Map form-runtime's narrow `rendererType` + separate `dataType` onto grid-kit's
|
|
1069
|
+
* 15-literal `CellRendererType`. form-runtime keeps the two separate (a column
|
|
1070
|
+
* can be `rendererType:'text'` + `dataType:'numeric'`), so a naive 1:1 map would
|
|
1071
|
+
* render numbers / links / toggles as plain text.
|
|
1072
|
+
*/
|
|
1073
|
+
function mapFormRuntimeRendererType(rendererType, dataType) {
|
|
1074
|
+
switch (rendererType) {
|
|
1075
|
+
case 'lookup':
|
|
1076
|
+
return 'lookup';
|
|
1077
|
+
case 'optionset':
|
|
1078
|
+
return 'optionset';
|
|
1079
|
+
case 'currency':
|
|
1080
|
+
return 'currency';
|
|
1081
|
+
case 'progress':
|
|
1082
|
+
return 'progress';
|
|
1083
|
+
case 'date':
|
|
1084
|
+
return dataType === 'datetime' ? 'datetime' : 'date';
|
|
1085
|
+
case 'datetime':
|
|
1086
|
+
return 'datetime';
|
|
1087
|
+
case 'boolean':
|
|
1088
|
+
return 'boolean';
|
|
1089
|
+
case 'rating':
|
|
1090
|
+
return 'rating';
|
|
1091
|
+
case 'composite':
|
|
1092
|
+
return 'composite';
|
|
1093
|
+
case 'text':
|
|
1094
|
+
default:
|
|
1095
|
+
switch (dataType) {
|
|
1096
|
+
case 'numeric':
|
|
1097
|
+
return 'number';
|
|
1098
|
+
case 'currency':
|
|
1099
|
+
return 'currency';
|
|
1100
|
+
case 'date':
|
|
1101
|
+
return 'date';
|
|
1102
|
+
case 'datetime':
|
|
1103
|
+
return 'datetime';
|
|
1104
|
+
case 'boolean':
|
|
1105
|
+
return 'boolean';
|
|
1106
|
+
case 'url':
|
|
1107
|
+
case 'email':
|
|
1108
|
+
return 'link';
|
|
1109
|
+
// No standalone image renderer — show the URL as text (use a composite
|
|
1110
|
+
// 'image' slot for an actual thumbnail).
|
|
1111
|
+
case 'image':
|
|
1112
|
+
return 'text';
|
|
1113
|
+
default:
|
|
1114
|
+
return 'text';
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
function mapEditorType(editorType) {
|
|
1119
|
+
switch (editorType) {
|
|
1120
|
+
case 'text':
|
|
1121
|
+
return 'text';
|
|
1122
|
+
case 'number':
|
|
1123
|
+
return 'number';
|
|
1124
|
+
case 'date':
|
|
1125
|
+
return 'date';
|
|
1126
|
+
case 'dropdown':
|
|
1127
|
+
return 'dropdown';
|
|
1128
|
+
case 'toggle':
|
|
1129
|
+
return 'toggle';
|
|
1130
|
+
case 'lookup':
|
|
1131
|
+
return 'lookup';
|
|
1132
|
+
case 'none':
|
|
1133
|
+
return 'none';
|
|
1134
|
+
default:
|
|
1135
|
+
return undefined;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function fromGridCustomizerColumn(col) {
|
|
1139
|
+
return {
|
|
1140
|
+
key: col.id ?? col.fieldName,
|
|
1141
|
+
fieldName: col.fieldName,
|
|
1142
|
+
name: col.displayName,
|
|
1143
|
+
rendererType: mapFormRuntimeRendererType(col.rendererType, col.dataType),
|
|
1144
|
+
rendererConfig: col.rendererConfig,
|
|
1145
|
+
editorType: mapEditorType(col.editorType),
|
|
1146
|
+
isLocked: col.isLocked,
|
|
1147
|
+
width: col.width,
|
|
1148
|
+
minWidth: col.minWidth,
|
|
1149
|
+
maxWidth: col.maxWidth,
|
|
1150
|
+
isResizable: col.isResizable,
|
|
1151
|
+
isSortable: col.isSortable,
|
|
1152
|
+
isFilterable: col.isFilterable,
|
|
1153
|
+
aggregate: col.aggregateFunction,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
function fromGridCustomizerDefinition(def) {
|
|
1157
|
+
return (def.columns ?? []).map((c) => fromGridCustomizerColumn(c));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** Default nested display mode by parent grid type (mirrors form-runtime's v11→v12 migration). */
|
|
1161
|
+
function defaultNestedMode(gridType) {
|
|
1162
|
+
if (gridType === 'focused-view')
|
|
1163
|
+
return 'detail-pane';
|
|
1164
|
+
if (gridType === 'card-list')
|
|
1165
|
+
return 'side-panel';
|
|
1166
|
+
return 'inline';
|
|
1167
|
+
}
|
|
1168
|
+
/** Build a FocusedViewConfig from a definition's focused-view settings (config or legacy fields). */
|
|
1169
|
+
function toFocusedViewConfig(fv) {
|
|
1170
|
+
if (fv?.config)
|
|
1171
|
+
return fv.config;
|
|
1172
|
+
const rows = [
|
|
1173
|
+
fv?.primaryNameField
|
|
1174
|
+
? { id: 'primary', primaryField: { fieldName: fv.primaryNameField, label: 'Name' } }
|
|
1175
|
+
: undefined,
|
|
1176
|
+
...(fv?.summaryFields ?? []).map((s, i) => ({
|
|
1177
|
+
id: `s${i}`,
|
|
1178
|
+
primaryField: { fieldName: s.fieldName, label: s.label },
|
|
1179
|
+
})),
|
|
1180
|
+
].filter(Boolean);
|
|
1181
|
+
return { rows: rows.length ? rows : [{ id: 'primary', primaryField: { fieldName: 'name', label: 'Name' } }] };
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Resolve a grid definition into the host + props grid-kit should render.
|
|
1185
|
+
* `nestedGridId` (with `opts.resolveNested`) wraps the result in a NestedGrid;
|
|
1186
|
+
* otherwise the `gridType` selects card / focused-view / readonly / data.
|
|
1187
|
+
*/
|
|
1188
|
+
function resolveGridFromDefinition(def, items, opts = {}) {
|
|
1189
|
+
const columns = fromGridCustomizerDefinition(def);
|
|
1190
|
+
const base = {
|
|
1191
|
+
items,
|
|
1192
|
+
columns,
|
|
1193
|
+
editable: def.gridType === 'editable' || (!!def.isEditable && def.gridType !== 'readonly'),
|
|
1194
|
+
selectionMode: def.gridType === 'readonly' ? 'none' : def.selectionMode ?? 'none',
|
|
1195
|
+
alternateRowColors: def.alternateRowColors,
|
|
1196
|
+
};
|
|
1197
|
+
if (def.nestedGridId && opts.resolveNested) {
|
|
1198
|
+
const { childColumns, getChildren } = opts.resolveNested(def);
|
|
1199
|
+
const nested = {
|
|
1200
|
+
mode: def.nestedDisplay?.mode ?? defaultNestedMode(def.gridType),
|
|
1201
|
+
childColumns,
|
|
1202
|
+
getChildren,
|
|
1203
|
+
panelSize: def.nestedDisplay?.panelSize,
|
|
1204
|
+
hoverDelay: def.nestedDisplay?.hoverDelay,
|
|
1205
|
+
calloutMaxRows: def.nestedDisplay?.calloutMaxRows,
|
|
1206
|
+
triggerLabel: def.nestedDisplay?.triggerLabel,
|
|
1207
|
+
triggerIcon: def.nestedDisplay?.triggerIcon,
|
|
1208
|
+
childSelectionMode: def.nestedDisplay?.childSelectionMode,
|
|
1209
|
+
childSelectionGating: def.nestedSelectionMode,
|
|
1210
|
+
childEditable: def.nestedDisplay?.childEditable,
|
|
1211
|
+
childEditTrigger: def.nestedDisplay?.childEditTrigger,
|
|
1212
|
+
};
|
|
1213
|
+
// A focused-view parent with a detail-pane child is a master-detail layout.
|
|
1214
|
+
if (def.gridType === 'focused-view') {
|
|
1215
|
+
return { host: 'focused-view', props: { ...base, focusedView: toFocusedViewConfig(def.focusedView), nested } };
|
|
1216
|
+
}
|
|
1217
|
+
return { host: 'nested', props: { ...base, nested } };
|
|
1218
|
+
}
|
|
1219
|
+
switch (def.gridType) {
|
|
1220
|
+
case 'card-list':
|
|
1221
|
+
return {
|
|
1222
|
+
host: 'card',
|
|
1223
|
+
props: {
|
|
1224
|
+
...base,
|
|
1225
|
+
card: {
|
|
1226
|
+
cardsPerRow: def.cardView?.cardsPerRow,
|
|
1227
|
+
cardHeight: def.cardView?.cardHeight,
|
|
1228
|
+
titleField: def.cardView?.titleField,
|
|
1229
|
+
subtitleField: def.cardView?.subtitleField,
|
|
1230
|
+
imageField: def.cardView?.imageField,
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1233
|
+
};
|
|
1234
|
+
case 'focused-view':
|
|
1235
|
+
return { host: 'focused-view', props: { ...base, focusedView: toFocusedViewConfig(def.focusedView) } };
|
|
1236
|
+
case 'readonly':
|
|
1237
|
+
return { host: 'readonly', props: base };
|
|
1238
|
+
case 'editable':
|
|
1239
|
+
case 'standard':
|
|
1240
|
+
default:
|
|
1241
|
+
return { host: 'data', props: base };
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Grid export helper — a thin wrapper over `@khester/dynamics-utils` export
|
|
1247
|
+
* utilities that maps grid-kit `ColumnDef`s onto the export `ExportColumn` shape.
|
|
1248
|
+
*
|
|
1249
|
+
* Kept OUT of `src/core/` (which is for v9-portable pure logic): this triggers a
|
|
1250
|
+
* browser download (`document` / `URL.createObjectURL`), a side-effectful host
|
|
1251
|
+
* concern.
|
|
1252
|
+
*/
|
|
1253
|
+
/**
|
|
1254
|
+
* Map grid-kit columns → dynamics-utils `ExportColumn[]`.
|
|
1255
|
+
* `ExportColumn.key` is the DATA field (so it reads `item[fieldName]`), and the
|
|
1256
|
+
* export formatter expects a canonical field type — reuse `rendererToFieldType`
|
|
1257
|
+
* rather than the grid-kit `rendererType` vocabulary. The grid-kit-native
|
|
1258
|
+
* `fileDrop` column has no exportable value and is skipped.
|
|
1259
|
+
*/
|
|
1260
|
+
function toExportColumns(columns) {
|
|
1261
|
+
return columns
|
|
1262
|
+
.filter((c) => c.rendererType !== 'fileDrop')
|
|
1263
|
+
.map((c) => ({
|
|
1264
|
+
key: c.fieldName,
|
|
1265
|
+
name: c.name,
|
|
1266
|
+
fieldType: rendererToFieldType(c.rendererType),
|
|
1267
|
+
}));
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Export grid rows to a CSV or JSON file (CSV = formatted strings, JSON = raw
|
|
1271
|
+
* values — see dynamics-utils). The consumer passes exactly the rows to export
|
|
1272
|
+
* (current page, all items, or the selection).
|
|
1273
|
+
*/
|
|
1274
|
+
function exportGrid(items, columns, format = 'csv', filename) {
|
|
1275
|
+
exportToFile(items, toExportColumns(columns), format, filename ?? generateDefaultFilename('grid-export'));
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const FORMAT_OPTIONS = {
|
|
1279
|
+
csv: { key: 'csv', text: 'CSV', iconProps: { iconName: 'ExcelDocument' } },
|
|
1280
|
+
json: { key: 'json', text: 'JSON', iconProps: { iconName: 'Code' } },
|
|
1281
|
+
};
|
|
1282
|
+
/** Strip characters illegal in filenames. */
|
|
1283
|
+
function sanitizeFilename(name) {
|
|
1284
|
+
return name.replace(/[<>:"/\\|?*]/g, '');
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* The export subset for a column selection: drops `fileDrop` columns (no
|
|
1288
|
+
* exportable value) and keeps only columns whose `key` is in `selectedKeys`.
|
|
1289
|
+
* Pure — the dialog's column-picker decision, extracted for testability.
|
|
1290
|
+
*/
|
|
1291
|
+
function resolveExportColumns(columns, selectedKeys) {
|
|
1292
|
+
return columns.filter((c) => c.rendererType !== 'fileDrop' && selectedKeys.has(c.key));
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Export dialog with format choice, a column picker (Select-all / indeterminate),
|
|
1296
|
+
* and a filename field. Exports the scoped `items` through `exportGrid`. The
|
|
1297
|
+
* turnkey `GridProps.exportConfig` split-button opens this when `dialog: true`.
|
|
1298
|
+
*/
|
|
1299
|
+
function GridExportDialog({ isOpen, onClose, items, columns, defaultFilename = 'grid-export', formats = ['csv', 'json'], }) {
|
|
1300
|
+
const exportable = useMemo(() => columns.filter((c) => c.rendererType !== 'fileDrop'), [columns]);
|
|
1301
|
+
const [format, setFormat] = useState(formats[0] ?? 'csv');
|
|
1302
|
+
const [filename, setFilename] = useState(() => generateDefaultFilename(defaultFilename));
|
|
1303
|
+
const [selected, setSelected] = useState(() => new Set(exportable.map((c) => c.key)));
|
|
1304
|
+
// Reset to defaults each time the dialog opens. Prop changes (formats /
|
|
1305
|
+
// defaultFilename / columns) while it stays OPEN are intentionally ignored.
|
|
1306
|
+
useEffect(() => {
|
|
1307
|
+
if (!isOpen)
|
|
1308
|
+
return;
|
|
1309
|
+
setFormat(formats[0] ?? 'csv');
|
|
1310
|
+
setFilename(generateDefaultFilename(defaultFilename));
|
|
1311
|
+
setSelected(new Set(exportable.map((c) => c.key)));
|
|
1312
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on the open transition
|
|
1313
|
+
}, [isOpen]);
|
|
1314
|
+
const allSelected = exportable.length > 0 && selected.size === exportable.length;
|
|
1315
|
+
const someSelected = selected.size > 0 && selected.size < exportable.length;
|
|
1316
|
+
const selectedColumns = useMemo(() => resolveExportColumns(columns, selected), [columns, selected]);
|
|
1317
|
+
const toggleAll = useCallback((_, checked) => {
|
|
1318
|
+
setSelected(checked ? new Set(exportable.map((c) => c.key)) : new Set());
|
|
1319
|
+
}, [exportable]);
|
|
1320
|
+
const toggleColumn = useCallback((key, checked) => {
|
|
1321
|
+
setSelected((prev) => {
|
|
1322
|
+
const next = new Set(prev);
|
|
1323
|
+
if (checked)
|
|
1324
|
+
next.add(key);
|
|
1325
|
+
else
|
|
1326
|
+
next.delete(key);
|
|
1327
|
+
return next;
|
|
1328
|
+
});
|
|
1329
|
+
}, []);
|
|
1330
|
+
const handleExport = useCallback(() => {
|
|
1331
|
+
if (selectedColumns.length === 0 || items.length === 0 || !filename.trim())
|
|
1332
|
+
return;
|
|
1333
|
+
exportGrid(items, selectedColumns, format, filename.trim());
|
|
1334
|
+
onClose();
|
|
1335
|
+
}, [items, selectedColumns, format, filename, onClose]);
|
|
1336
|
+
const formatChoices = formats.map((f) => FORMAT_OPTIONS[f]).filter(Boolean);
|
|
1337
|
+
return (jsxs(Dialog, { hidden: !isOpen, onDismiss: onClose, dialogContentProps: { type: DialogType.normal, title: 'Export', closeButtonAriaLabel: 'Close' }, modalProps: { isBlocking: false, styles: { main: { maxWidth: 480, minWidth: 380 } } }, children: [jsx("div", { style: { textAlign: 'center', padding: 10, background: '#f3f2f1', borderRadius: 4 }, children: jsxs(Text, { variant: "mediumPlus", children: ["Exporting ", jsx("strong", { children: items.length }), " ", items.length === 1 ? 'row' : 'rows'] }) }), jsx(Separator, {}), formatChoices.length > 1 && (jsxs("div", { style: { marginBottom: 12 }, children: [jsx(Text, { block: true, styles: { root: { fontWeight: 600, marginBottom: 6 } }, children: "Format" }), jsx(ChoiceGroup, { selectedKey: format, options: formatChoices, onChange: (_, o) => o && setFormat(o.key) })] })), jsxs("div", { style: { marginBottom: 12 }, children: [jsx(Text, { block: true, styles: { root: { fontWeight: 600, marginBottom: 6 } }, children: "Columns" }), jsxs("div", { style: { maxHeight: 200, overflowY: 'auto', border: '1px solid #edebe9', borderRadius: 4, background: '#faf9f8' }, children: [jsx("div", { style: { padding: '8px 12px', borderBottom: '1px solid #edebe9' }, children: jsx(Checkbox, { label: "Select all", checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }), exportable.map((c) => (jsx("div", { style: { padding: '4px 12px' }, children: jsx(Checkbox, { label: c.name, checked: selected.has(c.key), onChange: (_, ck) => toggleColumn(c.key, !!ck) }) }, c.key)))] }), jsxs(Text, { variant: "small", styles: { root: { color: '#605e5c', paddingTop: 4, display: 'block' } }, children: [selected.size, " of ", exportable.length, " columns"] })] }), jsx(TextField, { label: "Filename", value: filename, onChange: (_, v) => setFilename(sanitizeFilename(v ?? '')), suffix: `.${format}`, description: "Invalid characters are removed." }), jsxs(DialogFooter, { children: [jsx(PrimaryButton, { text: "Export", iconProps: { iconName: 'Download' }, onClick: handleExport, disabled: selectedColumns.length === 0 || items.length === 0 || !filename.trim() }), jsx(DefaultButton, { text: "Cancel", onClick: onClose })] })] }));
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const cellKey = (rowKey, fieldName) => `${rowKey}::${fieldName}`;
|
|
1341
|
+
function useEditState() {
|
|
1342
|
+
const [edits, setEdits] = useState({});
|
|
1343
|
+
const [errors, setErrors] = useState({});
|
|
1344
|
+
const [active, setActive] = useState(null);
|
|
1345
|
+
const onValueChange = useCallback((rowKey, fieldName, value, originalValue) => {
|
|
1346
|
+
setEdits((prev) => ({ ...prev, [cellKey(rowKey, fieldName)]: { value, original: originalValue } }));
|
|
1347
|
+
}, []);
|
|
1348
|
+
const onValidationError = useCallback((rowKey, fieldName, message) => {
|
|
1349
|
+
setErrors((prev) => {
|
|
1350
|
+
const k = cellKey(rowKey, fieldName);
|
|
1351
|
+
if (!message) {
|
|
1352
|
+
if (!(k in prev))
|
|
1353
|
+
return prev;
|
|
1354
|
+
const next = { ...prev };
|
|
1355
|
+
delete next[k];
|
|
1356
|
+
return next;
|
|
1357
|
+
}
|
|
1358
|
+
return { ...prev, [k]: message };
|
|
1359
|
+
});
|
|
1360
|
+
}, []);
|
|
1361
|
+
const isDirty = useCallback((rowKey, fieldName) => cellKey(rowKey, fieldName) in edits, [edits]);
|
|
1362
|
+
const getEditedValue = useCallback((rowKey, fieldName) => edits[cellKey(rowKey, fieldName)]?.value, [edits]);
|
|
1363
|
+
const getError = useCallback((rowKey, fieldName) => errors[cellKey(rowKey, fieldName)], [errors]);
|
|
1364
|
+
const isActiveCell = useCallback((rowKey, fieldName) => active === cellKey(rowKey, fieldName), [active]);
|
|
1365
|
+
const setActiveCell = useCallback((rowKey, fieldName) => setActive(cellKey(rowKey, fieldName)), []);
|
|
1366
|
+
const clearActiveCell = useCallback(() => setActive(null), []);
|
|
1367
|
+
const getChanges = useCallback(() => {
|
|
1368
|
+
const out = {};
|
|
1369
|
+
for (const k of Object.keys(edits)) {
|
|
1370
|
+
const sep = k.indexOf('::');
|
|
1371
|
+
const rowKey = k.slice(0, sep);
|
|
1372
|
+
const fieldName = k.slice(sep + 2);
|
|
1373
|
+
(out[rowKey] = out[rowKey] ?? {})[fieldName] = edits[k].value;
|
|
1374
|
+
}
|
|
1375
|
+
return out;
|
|
1376
|
+
}, [edits]);
|
|
1377
|
+
const reset = useCallback(() => {
|
|
1378
|
+
setEdits({});
|
|
1379
|
+
setErrors({});
|
|
1380
|
+
setActive(null);
|
|
1381
|
+
}, []);
|
|
1382
|
+
const hasErrors = Object.keys(errors).length > 0;
|
|
1383
|
+
// Stable object identity: only changes when edits/errors/active change (the
|
|
1384
|
+
// derived callbacks change identity then), so the grid's ctx/columns recompute
|
|
1385
|
+
// on real edit-state changes, not on every render.
|
|
1386
|
+
return useMemo(() => ({
|
|
1387
|
+
onValueChange,
|
|
1388
|
+
onValidationError,
|
|
1389
|
+
isDirty,
|
|
1390
|
+
getEditedValue,
|
|
1391
|
+
getError,
|
|
1392
|
+
isActiveCell,
|
|
1393
|
+
setActiveCell,
|
|
1394
|
+
clearActiveCell,
|
|
1395
|
+
getChanges,
|
|
1396
|
+
hasErrors,
|
|
1397
|
+
reset,
|
|
1398
|
+
}), [
|
|
1399
|
+
onValueChange,
|
|
1400
|
+
onValidationError,
|
|
1401
|
+
isDirty,
|
|
1402
|
+
getEditedValue,
|
|
1403
|
+
getError,
|
|
1404
|
+
isActiveCell,
|
|
1405
|
+
setActiveCell,
|
|
1406
|
+
clearActiveCell,
|
|
1407
|
+
getChanges,
|
|
1408
|
+
hasErrors,
|
|
1409
|
+
reset,
|
|
1410
|
+
]);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Builds the per-render `GridRenderContext` shared by every grid host
|
|
1415
|
+
* (DataGrid, CardGrid, GroupedGrid, …). Resolves the registry + key accessor,
|
|
1416
|
+
* threads edit-state, and wires per-cell click-to-edit so all hosts behave
|
|
1417
|
+
* identically. Extracted from DataGrid so the hosts don't each re-implement it.
|
|
1418
|
+
*/
|
|
1419
|
+
function useGridContext(props, edit) {
|
|
1420
|
+
const { registry: providedRegistry, editable, editTrigger, sort, onSortChange, getKey, onValueChange, navigateTo } = props;
|
|
1421
|
+
const registry = useMemo(() => providedRegistry ?? createCellRegistry(), [providedRegistry]);
|
|
1422
|
+
const getKeyFn = useMemo(() => getKey ?? defaultGetKey, [getKey]);
|
|
1423
|
+
const trigger = editTrigger ?? 'click';
|
|
1424
|
+
const ctx = useMemo(() => ({
|
|
1425
|
+
registry,
|
|
1426
|
+
editable,
|
|
1427
|
+
editTrigger: trigger,
|
|
1428
|
+
getKey: getKeyFn,
|
|
1429
|
+
sort,
|
|
1430
|
+
onSortChange,
|
|
1431
|
+
navigateTo,
|
|
1432
|
+
// Per-cell: only the active cell is in edit mode. 'always': every editable cell.
|
|
1433
|
+
isEditing: (rk, fn) => !!editable && (trigger === 'always' || edit.isActiveCell(rk, fn)),
|
|
1434
|
+
isDirty: edit.isDirty,
|
|
1435
|
+
getEditedValue: edit.getEditedValue,
|
|
1436
|
+
getError: edit.getError,
|
|
1437
|
+
onValueChange: (rk, fn, v, ov) => {
|
|
1438
|
+
edit.onValueChange(rk, fn, v, ov);
|
|
1439
|
+
onValueChange?.(rk, fn, v, ov);
|
|
1440
|
+
},
|
|
1441
|
+
onValidationError: edit.onValidationError,
|
|
1442
|
+
onEditStart: (rk, fn) => edit.setActiveCell(rk, fn),
|
|
1443
|
+
onEditEnd: () => edit.clearActiveCell(),
|
|
1444
|
+
}), [registry, editable, trigger, getKeyFn, sort, onSortChange, edit, onValueChange, navigateTo]);
|
|
1445
|
+
return { registry, getKeyFn, ctx };
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Pure projection of a row's `RowCommand[]` → Fluent `ContextualMenu` items for a
|
|
1450
|
+
* given row. Commands whose `visible(item)` returns `false` are dropped; `disabled`
|
|
1451
|
+
* is resolved per row; `onClick` is bound to the row and chained with `onAfter`
|
|
1452
|
+
* (used by the host to dismiss the menu). No React / no rendering — unit-testable in
|
|
1453
|
+
* grid-kit's node test env (the type-only Fluent import has no runtime cost).
|
|
1454
|
+
*/
|
|
1455
|
+
function buildRowMenuItems(commands, item, onAfter) {
|
|
1456
|
+
return commands
|
|
1457
|
+
.filter((c) => c.visible?.(item) !== false)
|
|
1458
|
+
.map((c) => ({
|
|
1459
|
+
key: c.key,
|
|
1460
|
+
text: c.text,
|
|
1461
|
+
iconProps: c.iconName ? { iconName: c.iconName } : undefined,
|
|
1462
|
+
disabled: c.disabled?.(item) ?? false,
|
|
1463
|
+
onClick: () => {
|
|
1464
|
+
c.onClick(item);
|
|
1465
|
+
onAfter();
|
|
1466
|
+
},
|
|
1467
|
+
}));
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Right-click row context menu for the DetailsList-backed hosts (`DataGrid`,
|
|
1472
|
+
* `NestedInline`). Wire the returned `onItemContextMenu` onto `ShimmeredDetailsList`
|
|
1473
|
+
* and render `contextMenuElement` alongside it.
|
|
1474
|
+
*
|
|
1475
|
+
* Backward-compat: when `rowCommands` is unset/empty, `onItemContextMenu` is
|
|
1476
|
+
* `undefined`, so the host attaches no handler and the native browser context menu is
|
|
1477
|
+
* left intact for existing consumers. When set, the handler stores the clicked row +
|
|
1478
|
+
* the cursor event and returns `false` to suppress the native menu (Fluent calls
|
|
1479
|
+
* `preventDefault` unless the handler returns a truthy value — do NOT return `true`).
|
|
1480
|
+
*/
|
|
1481
|
+
function useRowContextMenu(rowCommands) {
|
|
1482
|
+
const [menu, setMenu] = useState(null);
|
|
1483
|
+
const enabled = !!rowCommands && rowCommands.length > 0;
|
|
1484
|
+
const onItemContextMenu = enabled
|
|
1485
|
+
? (item, _index, ev) => {
|
|
1486
|
+
if (item == null || ev == null)
|
|
1487
|
+
return;
|
|
1488
|
+
setMenu({ item, target: ev });
|
|
1489
|
+
return false; // suppress the native browser menu
|
|
1490
|
+
}
|
|
1491
|
+
: undefined;
|
|
1492
|
+
const contextMenuElement = enabled && menu ? (jsx(ContextualMenu, { target: menu.target, items: buildRowMenuItems(rowCommands, menu.item, () => setMenu(null)), onDismiss: () => setMenu(null) })) : null;
|
|
1493
|
+
return { onItemContextMenu, contextMenuElement };
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Grid toolbar: a command bar (left) + optional search box (right).
|
|
1498
|
+
*/
|
|
1499
|
+
const GridToolbar = ({ config }) => {
|
|
1500
|
+
const items = (config.commands ?? []).map((c) => ({
|
|
1501
|
+
key: c.key,
|
|
1502
|
+
text: c.text,
|
|
1503
|
+
iconProps: c.iconName ? { iconName: c.iconName } : undefined,
|
|
1504
|
+
onClick: c.onClick,
|
|
1505
|
+
disabled: c.disabled,
|
|
1506
|
+
split: c.split,
|
|
1507
|
+
subMenuProps: c.subMenuProps,
|
|
1508
|
+
}));
|
|
1509
|
+
return (jsxs(Stack, { horizontal: true, horizontalAlign: "space-between", verticalAlign: "center", tokens: { childrenGap: 8 }, styles: { root: { paddingBottom: 4 } }, children: [jsx(Stack.Item, { grow: true, children: items.length > 0 ? (jsx(CommandBar, { items: items, styles: { root: { padding: 0, height: 36 } } })) : (jsx("span", {})) }), config.showSearch && (jsx(SearchBox, { placeholder: config.searchPlaceholder ?? 'Search', onChange: (_, v) => config.onSearch?.(v ?? ''), styles: { root: { width: 240 } } }))] }));
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Presentational pager shared by all hosts. The consumer pre-slices `items` to
|
|
1514
|
+
* the page; this only renders the page indicator + prev/next controls.
|
|
1515
|
+
*/
|
|
1516
|
+
const GridPaginationFooter = ({ pagination, onPageChange }) => {
|
|
1517
|
+
const { currentPage, pageSize, totalItems } = pagination;
|
|
1518
|
+
const totalPages = Math.max(1, Math.ceil(totalItems / Math.max(1, pageSize)));
|
|
1519
|
+
return (jsxs(Stack, { horizontal: true, verticalAlign: "center", horizontalAlign: "end", tokens: { childrenGap: 8 }, styles: { root: { paddingTop: 8 } }, children: [jsx(Text, { variant: "small", children: `Page ${currentPage} of ${totalPages} · ${totalItems} item${totalItems === 1 ? '' : 's'}` }), jsx(DefaultButton, { text: "Previous", iconProps: { iconName: 'ChevronLeft' }, disabled: currentPage <= 1, onClick: () => onPageChange?.(currentPage - 1) }), jsx(DefaultButton, { text: "Next", iconProps: { iconName: 'ChevronRight' }, disabled: currentPage >= totalPages, onClick: () => onPageChange?.(currentPage + 1) })] }));
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
const AGG_LABEL = {
|
|
1523
|
+
sum: 'Σ',
|
|
1524
|
+
avg: 'avg',
|
|
1525
|
+
count: 'count',
|
|
1526
|
+
min: 'min',
|
|
1527
|
+
max: 'max',
|
|
1528
|
+
};
|
|
1529
|
+
/** Format one aggregate cell: `<label> <value>` (currency sum → currency, etc.). */
|
|
1530
|
+
function aggregateCellText(col, agg) {
|
|
1531
|
+
if (!agg)
|
|
1532
|
+
return '';
|
|
1533
|
+
let text = '';
|
|
1534
|
+
if (col.aggregateFormat) {
|
|
1535
|
+
text = col.aggregateFormat(agg.value, agg.fn);
|
|
1536
|
+
}
|
|
1537
|
+
else if (agg.value === null) {
|
|
1538
|
+
text = '';
|
|
1539
|
+
}
|
|
1540
|
+
else if (agg.fn === 'count') {
|
|
1541
|
+
text = String(agg.value);
|
|
1542
|
+
}
|
|
1543
|
+
else {
|
|
1544
|
+
const cfg = (col.rendererConfig ?? {});
|
|
1545
|
+
text = computeFormattedValue(agg.value, col.rendererType, {
|
|
1546
|
+
currencyCode: cfg.currencyCode,
|
|
1547
|
+
locale: cfg.locale,
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
return `${AGG_LABEL[agg.fn]} ${text}`.trim();
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Presentational aggregate row: one cell per column (text only for columns that
|
|
1554
|
+
* declared an `aggregate`), constrained to each column's min/max width and offset
|
|
1555
|
+
* by `leadingSpacer`. Shared by the grid footer and per-group subtotal footers.
|
|
1556
|
+
*
|
|
1557
|
+
* Column alignment vs the DetailsList's justified layout is approximate (a known
|
|
1558
|
+
* visual limitation); `leadingSpacer` offsets leading control columns (selection
|
|
1559
|
+
* checkbox, group-expand chevron).
|
|
1560
|
+
*/
|
|
1561
|
+
function AggregateRow(props) {
|
|
1562
|
+
const { columns, aggregates, leadingSpacer, background } = props;
|
|
1563
|
+
return (jsxs(Stack, { horizontal: true, verticalAlign: "center", role: "row", styles: { root: { borderTop: '1px solid #edebe9', background: background ?? '#faf9f8', minHeight: 32 } }, children: [leadingSpacer ? jsx("div", { role: "presentation", style: { width: leadingSpacer, flexShrink: 0 } }) : null, columns.map((col) => {
|
|
1564
|
+
const label = aggregateCellText(col, aggregates[col.fieldName]);
|
|
1565
|
+
return (jsx("div", { role: "gridcell", title: label || undefined, style: {
|
|
1566
|
+
minWidth: col.minWidth ?? 100,
|
|
1567
|
+
maxWidth: col.maxWidth,
|
|
1568
|
+
width: col.maxWidth ?? col.minWidth ?? 100,
|
|
1569
|
+
padding: '4px 8px',
|
|
1570
|
+
fontSize: 12,
|
|
1571
|
+
fontWeight: 600,
|
|
1572
|
+
color: '#323130',
|
|
1573
|
+
overflow: 'hidden',
|
|
1574
|
+
textOverflow: 'ellipsis',
|
|
1575
|
+
whiteSpace: 'nowrap',
|
|
1576
|
+
}, children: label }, col.key));
|
|
1577
|
+
})] }));
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Footer row of per-column aggregates, rendered as a sibling Stack BELOW the
|
|
1581
|
+
* DetailsList (Fluent v8 has no first-class footer-row API; mirrors how
|
|
1582
|
+
* GridPaginationFooter mounts).
|
|
1583
|
+
*
|
|
1584
|
+
* Aggregates over exactly the `items` passed — the displayed (filtered) page by
|
|
1585
|
+
* default, or the full dataset when the host is given `aggregateItems` (cross-page
|
|
1586
|
+
* grand totals). `leadingSpacer` offsets a leading selection-check column.
|
|
1587
|
+
*/
|
|
1588
|
+
function GridAggregateFooter(props) {
|
|
1589
|
+
const { items, columns, leadingSpacer } = props;
|
|
1590
|
+
const aggregates = useMemo(() => computeAggregates(items, columns), [items, columns]);
|
|
1591
|
+
return jsx(AggregateRow, { columns: columns, aggregates: aggregates, leadingSpacer: leadingSpacer });
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* Project a column list to its visible, ordered subset per `visibleOrder` (keys
|
|
1596
|
+
* not in `columns` are skipped; columns not in `visibleOrder` are hidden). Pure —
|
|
1597
|
+
* the chooser's effect on the rendered grid, extracted for testability.
|
|
1598
|
+
*/
|
|
1599
|
+
function orderVisibleColumns(columns, visibleOrder) {
|
|
1600
|
+
const byKey = new Map(columns.map((c) => [c.key, c]));
|
|
1601
|
+
return visibleOrder.map((k) => byKey.get(k)).filter((c) => !!c);
|
|
1602
|
+
}
|
|
1603
|
+
const rowStyle = {
|
|
1604
|
+
display: 'flex',
|
|
1605
|
+
alignItems: 'center',
|
|
1606
|
+
padding: '4px 0',
|
|
1607
|
+
borderBottom: '1px solid #f3f2f1',
|
|
1608
|
+
};
|
|
1609
|
+
const sectionTitle = { root: { fontWeight: 600, color: '#605e5c', padding: '8px 0 4px' } };
|
|
1610
|
+
/**
|
|
1611
|
+
* Column chooser panel: show/hide grid columns and reorder the shown ones.
|
|
1612
|
+
* Edits a working copy and commits on Apply (reset on each open). At least one
|
|
1613
|
+
* column must stay shown.
|
|
1614
|
+
*/
|
|
1615
|
+
function GridColumnChooser({ isOpen, onClose, columns, visibleOrder, onApply, }) {
|
|
1616
|
+
const [working, setWorking] = useState(visibleOrder);
|
|
1617
|
+
// Reset the working copy each time the panel opens.
|
|
1618
|
+
useEffect(() => {
|
|
1619
|
+
if (isOpen)
|
|
1620
|
+
setWorking(visibleOrder);
|
|
1621
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on the open transition
|
|
1622
|
+
}, [isOpen]);
|
|
1623
|
+
const byKey = useMemo(() => new Map(columns.map((c) => [c.key, c])), [columns]);
|
|
1624
|
+
const shown = working.map((k) => byKey.get(k)).filter((c) => !!c);
|
|
1625
|
+
const hidden = columns.filter((c) => !working.includes(c.key));
|
|
1626
|
+
const show = useCallback((key) => {
|
|
1627
|
+
setWorking((prev) => (prev.includes(key) ? prev : [...prev, key]));
|
|
1628
|
+
}, []);
|
|
1629
|
+
const hide = useCallback((key) => {
|
|
1630
|
+
setWorking((prev) => (prev.length <= 1 ? prev : prev.filter((k) => k !== key)));
|
|
1631
|
+
}, []);
|
|
1632
|
+
const move = useCallback((key, dir) => {
|
|
1633
|
+
setWorking((prev) => {
|
|
1634
|
+
const i = prev.indexOf(key);
|
|
1635
|
+
const j = i + dir;
|
|
1636
|
+
if (i < 0 || j < 0 || j >= prev.length)
|
|
1637
|
+
return prev;
|
|
1638
|
+
const next = [...prev];
|
|
1639
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
1640
|
+
return next;
|
|
1641
|
+
});
|
|
1642
|
+
}, []);
|
|
1643
|
+
const showAll = useCallback(() => setWorking(columns.map((c) => c.key)), [columns]);
|
|
1644
|
+
const reset = useCallback(() => setWorking(visibleOrder), [visibleOrder]);
|
|
1645
|
+
const apply = useCallback(() => {
|
|
1646
|
+
if (working.length === 0)
|
|
1647
|
+
return;
|
|
1648
|
+
onApply(working);
|
|
1649
|
+
onClose();
|
|
1650
|
+
}, [working, onApply, onClose]);
|
|
1651
|
+
return (jsxs(Panel, { isOpen: isOpen, onDismiss: onClose, type: PanelType.medium, headerText: "Columns", isFooterAtBottom: true, onRenderFooterContent: () => (jsxs(Stack, { horizontal: true, horizontalAlign: "end", tokens: { childrenGap: 8 }, styles: { root: { padding: '12px 24px', borderTop: '1px solid #edebe9' } }, children: [jsx(DefaultButton, { text: "Cancel", onClick: onClose }), jsx(PrimaryButton, { text: "Apply", onClick: apply, disabled: working.length === 0 })] })), children: [jsxs(Stack, { horizontal: true, tokens: { childrenGap: 8 }, styles: { root: { padding: '8px 0' } }, children: [jsx(DefaultButton, { text: "Show all", onClick: showAll, disabled: hidden.length === 0 }), jsx(DefaultButton, { text: "Reset", onClick: reset })] }), jsxs(Text, { block: true, styles: sectionTitle, children: ["Shown (", shown.length, ")"] }), shown.map((c, i) => (jsxs("div", { style: rowStyle, children: [jsx(IconButton, { iconProps: { iconName: 'ChevronUp' }, ariaLabel: `Move ${c.name} up`, onClick: () => move(c.key, -1), disabled: i === 0, styles: { root: { height: 28, width: 28 } } }), jsx(IconButton, { iconProps: { iconName: 'ChevronDown' }, ariaLabel: `Move ${c.name} down`, onClick: () => move(c.key, 1), disabled: i === shown.length - 1, styles: { root: { height: 28, width: 28 } } }), jsx(Checkbox, { checked: true, ariaLabel: `Hide ${c.name}`, onChange: () => hide(c.key), disabled: shown.length === 1, styles: { root: { marginLeft: 4 } } }), jsx("span", { style: { flex: 1, marginLeft: 8 }, children: c.name })] }, c.key))), jsx(Separator, {}), jsxs(Text, { block: true, styles: sectionTitle, children: ["Hidden (", hidden.length, ")"] }), hidden.length === 0 ? (jsx(Text, { variant: "small", styles: { root: { color: '#a19f9d' } }, children: "All columns are shown." })) : (hidden.map((c) => (jsxs("div", { style: rowStyle, children: [jsx(Checkbox, { checked: false, ariaLabel: `Show ${c.name}`, onChange: () => show(c.key) }), jsx("span", { style: { flex: 1, marginLeft: 8 }, children: c.name })] }, c.key))))] }));
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/** Map a column's renderer type onto a filter field-type bucket. */
|
|
1655
|
+
function fieldTypeForRenderer(rendererType) {
|
|
1656
|
+
switch (rendererToFieldType(rendererType)) {
|
|
1657
|
+
case 'number':
|
|
1658
|
+
case 'progressBar':
|
|
1659
|
+
case 'rating':
|
|
1660
|
+
return 'number';
|
|
1661
|
+
case 'currency':
|
|
1662
|
+
return 'currency';
|
|
1663
|
+
case 'date':
|
|
1664
|
+
case 'datetime':
|
|
1665
|
+
return 'date';
|
|
1666
|
+
case 'toggle':
|
|
1667
|
+
return 'boolean';
|
|
1668
|
+
case 'optionset':
|
|
1669
|
+
return 'optionset';
|
|
1670
|
+
case 'lookup':
|
|
1671
|
+
return 'lookup';
|
|
1672
|
+
default:
|
|
1673
|
+
return 'text';
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
const OPS_BY_TYPE = {
|
|
1677
|
+
text: ['eq', 'ne', 'like', 'not-like', 'begins-with', 'ends-with', 'null', 'not-null'],
|
|
1678
|
+
number: ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'null', 'not-null'],
|
|
1679
|
+
currency: ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'null', 'not-null'],
|
|
1680
|
+
date: ['eq', 'ne', 'on-or-after', 'on-or-before', 'gt', 'lt', 'null', 'not-null'],
|
|
1681
|
+
boolean: ['eq', 'ne'],
|
|
1682
|
+
optionset: ['eq', 'ne', 'in', 'not-in', 'null', 'not-null'],
|
|
1683
|
+
lookup: ['eq', 'ne', 'like', 'null', 'not-null'],
|
|
1684
|
+
};
|
|
1685
|
+
const OP_LABELS = {
|
|
1686
|
+
eq: 'Equals',
|
|
1687
|
+
ne: 'Does Not Equal',
|
|
1688
|
+
like: 'Contains',
|
|
1689
|
+
'not-like': 'Does Not Contain',
|
|
1690
|
+
'begins-with': 'Begins With',
|
|
1691
|
+
'ends-with': 'Ends With',
|
|
1692
|
+
gt: 'Greater Than',
|
|
1693
|
+
ge: 'Greater Or Equal',
|
|
1694
|
+
lt: 'Less Than',
|
|
1695
|
+
le: 'Less Or Equal',
|
|
1696
|
+
'on-or-after': 'On Or After',
|
|
1697
|
+
'on-or-before': 'On Or Before',
|
|
1698
|
+
in: 'Is One Of',
|
|
1699
|
+
'not-in': 'Is Not One Of',
|
|
1700
|
+
null: 'Is Empty',
|
|
1701
|
+
'not-null': 'Is Not Empty',
|
|
1702
|
+
};
|
|
1703
|
+
/** Operators for a field type, with display labels (date relabels gt/lt to After/Before). */
|
|
1704
|
+
function getOperatorsForFieldType(t) {
|
|
1705
|
+
return OPS_BY_TYPE[t].map((op) => {
|
|
1706
|
+
const label = t === 'date' && op === 'gt' ? 'After' : t === 'date' && op === 'lt' ? 'Before' : OP_LABELS[op];
|
|
1707
|
+
return { op, label };
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
/** Read optionset choices off a column's renderer config (both key shapes). */
|
|
1711
|
+
function normalizeOptions(col) {
|
|
1712
|
+
const cfg = (col.rendererConfig ?? {});
|
|
1713
|
+
const raw = cfg.optionSetOptions ?? cfg.options;
|
|
1714
|
+
if (!Array.isArray(raw))
|
|
1715
|
+
return undefined;
|
|
1716
|
+
return raw
|
|
1717
|
+
.map((o) => {
|
|
1718
|
+
const opt = o;
|
|
1719
|
+
const key = opt.key ?? opt.value;
|
|
1720
|
+
if (key === undefined || key === null)
|
|
1721
|
+
return undefined;
|
|
1722
|
+
const text = opt.text ?? opt.label ?? key;
|
|
1723
|
+
return { key: String(key), text: String(text) };
|
|
1724
|
+
})
|
|
1725
|
+
.filter((o) => !!o);
|
|
1726
|
+
}
|
|
1727
|
+
function toFilterField(col) {
|
|
1728
|
+
let fieldType = fieldTypeForRenderer(col.rendererType);
|
|
1729
|
+
const options = fieldType === 'optionset' ? normalizeOptions(col) : undefined;
|
|
1730
|
+
// An optionset column with no declared choices behaves as text — otherwise its
|
|
1731
|
+
// `in`/`not-in` operators would render a text box that writes `value`, never the
|
|
1732
|
+
// `values` they require, leaving the condition permanently incomplete.
|
|
1733
|
+
if (fieldType === 'optionset' && (!options || options.length === 0))
|
|
1734
|
+
fieldType = 'text';
|
|
1735
|
+
return { key: col.fieldName, text: col.name, fieldType, options };
|
|
1736
|
+
}
|
|
1737
|
+
/** Format a Date as a local calendar day (YYYY-MM-DD) — avoids the toISOString UTC shift. */
|
|
1738
|
+
function toDateOnly(d) {
|
|
1739
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1740
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
1741
|
+
}
|
|
1742
|
+
/** Parse a YYYY-MM-DD (or ISO) value into a LOCAL Date for the picker (round-trips the day). */
|
|
1743
|
+
function parseDateValue(value) {
|
|
1744
|
+
if (!value)
|
|
1745
|
+
return undefined;
|
|
1746
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
|
1747
|
+
const d = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(value);
|
|
1748
|
+
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
1749
|
+
}
|
|
1750
|
+
/** Per-condition value editor — switches on the column's field type + operator. */
|
|
1751
|
+
function ValueEditor({ field, condition, onChange, }) {
|
|
1752
|
+
const fieldType = field?.fieldType ?? 'text';
|
|
1753
|
+
if (fieldType === 'boolean') {
|
|
1754
|
+
return (jsx(Dropdown, { ariaLabel: "Value", selectedKey: condition.value ?? null, options: [
|
|
1755
|
+
{ key: 'true', text: 'Yes' },
|
|
1756
|
+
{ key: 'false', text: 'No' },
|
|
1757
|
+
], onChange: (_, o) => o && onChange({ value: String(o.key) }), styles: { root: { width: 120 } } }));
|
|
1758
|
+
}
|
|
1759
|
+
if (fieldType === 'optionset' && field?.options) {
|
|
1760
|
+
if (operatorIsMultiValue(condition.operator)) {
|
|
1761
|
+
return (jsx(Dropdown, { multiSelect: true, ariaLabel: "Values", selectedKeys: condition.values ?? [], options: field.options, onChange: (_, o) => {
|
|
1762
|
+
if (!o)
|
|
1763
|
+
return;
|
|
1764
|
+
const prev = condition.values ?? [];
|
|
1765
|
+
const key = String(o.key);
|
|
1766
|
+
onChange({ values: o.selected ? [...prev, key] : prev.filter((k) => k !== key) });
|
|
1767
|
+
}, styles: { root: { width: 190 } } }));
|
|
1768
|
+
}
|
|
1769
|
+
return (jsx(Dropdown, { ariaLabel: "Value", selectedKey: condition.value ?? null, options: field.options, onChange: (_, o) => o && onChange({ value: String(o.key) }), styles: { root: { width: 190 } } }));
|
|
1770
|
+
}
|
|
1771
|
+
if (fieldType === 'date') {
|
|
1772
|
+
return (jsx(DatePicker, { ariaLabel: "Value", value: parseDateValue(condition.value), onSelectDate: (d) => onChange({ value: d ? toDateOnly(d) : undefined }), styles: { root: { width: 170 } } }));
|
|
1773
|
+
}
|
|
1774
|
+
const isNumeric = fieldType === 'number' || fieldType === 'currency';
|
|
1775
|
+
return (jsx(TextField, { ariaLabel: "Value", type: isNumeric ? 'number' : 'text', value: condition.value ?? '', placeholder: fieldType === 'lookup' ? 'Text or GUID' : undefined, onChange: (_, v) => onChange({ value: v ?? '' }), styles: { root: { width: 170 } } }));
|
|
1776
|
+
}
|
|
1777
|
+
/** One filter row: column → operator → value editor → remove. */
|
|
1778
|
+
function ConditionRow({ condition, fields, onChange, onRemove, }) {
|
|
1779
|
+
const field = fields.find((f) => f.key === condition.fieldName);
|
|
1780
|
+
const fieldType = field?.fieldType ?? 'text';
|
|
1781
|
+
const operators = getOperatorsForFieldType(fieldType);
|
|
1782
|
+
const onFieldChange = (key) => {
|
|
1783
|
+
const next = fields.find((f) => f.key === key);
|
|
1784
|
+
const firstOp = next ? getOperatorsForFieldType(next.fieldType)[0].op : 'eq';
|
|
1785
|
+
onChange({ fieldName: key, operator: firstOp, value: undefined, values: undefined });
|
|
1786
|
+
};
|
|
1787
|
+
return (jsxs(Stack, { horizontal: true, verticalAlign: "start", tokens: { childrenGap: 8 }, styles: { root: { padding: '2px 0' } }, children: [jsx(Dropdown, { ariaLabel: "Column", placeholder: "Select a column", selectedKey: condition.fieldName || null, options: fields.map((f) => ({ key: f.key, text: f.text })), onChange: (_, o) => o && onFieldChange(String(o.key)), styles: { root: { width: 170 } } }), jsx(Dropdown, { ariaLabel: "Operator", selectedKey: condition.operator, disabled: !condition.fieldName, options: operators.map((o) => ({ key: o.op, text: o.label })), onChange: (_, o) => o && onChange({ operator: o.key, value: undefined, values: undefined }), styles: { root: { width: 150 } } }), condition.fieldName && operatorRequiresValue(condition.operator) && (jsx(ValueEditor, { field: field, condition: condition, onChange: onChange })), jsx(IconButton, { iconProps: { iconName: 'Delete' }, ariaLabel: "Remove condition", title: "Remove condition", onClick: onRemove })] }));
|
|
1788
|
+
}
|
|
1789
|
+
let rowSeq = 0;
|
|
1790
|
+
const makeRow = (cond) => ({ id: ++rowSeq, cond });
|
|
1791
|
+
/**
|
|
1792
|
+
* Filter-builder panel: build field/operator/value conditions combined by All/Any
|
|
1793
|
+
* (AND/OR). Edits a working copy and commits on Apply (reset on each open).
|
|
1794
|
+
* Incomplete rows are dropped on Apply. The actual row filtering is done by the
|
|
1795
|
+
* pure `applyFilters` (src/core/filter.ts); this panel only produces the model.
|
|
1796
|
+
*/
|
|
1797
|
+
function GridFilterBuilder({ isOpen, onClose, columns, model, onApply, }) {
|
|
1798
|
+
const fields = useMemo(() => {
|
|
1799
|
+
const seen = new Set();
|
|
1800
|
+
const out = [];
|
|
1801
|
+
for (const col of columns) {
|
|
1802
|
+
if (col.isFilterable === false || col.rendererType === 'fileDrop')
|
|
1803
|
+
continue;
|
|
1804
|
+
if (seen.has(col.fieldName))
|
|
1805
|
+
continue;
|
|
1806
|
+
seen.add(col.fieldName);
|
|
1807
|
+
out.push(toFilterField(col));
|
|
1808
|
+
}
|
|
1809
|
+
return out;
|
|
1810
|
+
}, [columns]);
|
|
1811
|
+
const [match, setMatch] = useState(model.match);
|
|
1812
|
+
const [rows, setRows] = useState(() => model.conditions.map(makeRow));
|
|
1813
|
+
// Reset the working copy each time the panel opens (mirrors GridColumnChooser).
|
|
1814
|
+
useEffect(() => {
|
|
1815
|
+
if (isOpen) {
|
|
1816
|
+
setMatch(model.match);
|
|
1817
|
+
setRows(model.conditions.map(makeRow));
|
|
1818
|
+
}
|
|
1819
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on the open transition
|
|
1820
|
+
}, [isOpen]);
|
|
1821
|
+
const addCondition = () => setRows((rs) => [...rs, makeRow({ fieldName: '', operator: 'eq' })]);
|
|
1822
|
+
const updateCondition = (id, patch) => setRows((rs) => rs.map((r) => (r.id === id ? { ...r, cond: { ...r.cond, ...patch } } : r)));
|
|
1823
|
+
const removeCondition = (id) => setRows((rs) => rs.filter((r) => r.id !== id));
|
|
1824
|
+
const clearAll = () => setRows([]);
|
|
1825
|
+
const apply = () => {
|
|
1826
|
+
onApply({ match, conditions: rows.map((r) => r.cond).filter(isConditionComplete) });
|
|
1827
|
+
onClose();
|
|
1828
|
+
};
|
|
1829
|
+
return (jsxs(Panel, { isOpen: isOpen, onDismiss: onClose, type: PanelType.medium, headerText: "Edit filters", isFooterAtBottom: true, onRenderFooterContent: () => (jsxs(Stack, { horizontal: true, horizontalAlign: "end", tokens: { childrenGap: 8 }, styles: { root: { padding: '12px 24px', borderTop: '1px solid #edebe9' } }, children: [jsx(DefaultButton, { text: "Cancel", onClick: onClose }), jsx(PrimaryButton, { text: "Apply", onClick: apply })] })), children: [rows.length > 1 && (jsx(Dropdown, { label: "Match", selectedKey: match, options: [
|
|
1830
|
+
{ key: 'all', text: 'All conditions (AND)' },
|
|
1831
|
+
{ key: 'any', text: 'Any condition (OR)' },
|
|
1832
|
+
], onChange: (_, o) => o && setMatch(o.key), styles: { root: { width: 220, padding: '8px 0' } } })), rows.length === 0 ? (jsx(Text, { variant: "small", styles: { root: { color: '#a19f9d', display: 'block', padding: '8px 0' } }, children: "No filters. Add a condition to narrow the rows." })) : (jsx(Stack, { tokens: { childrenGap: 4 }, styles: { root: { padding: '8px 0' } }, children: rows.map((r) => (jsx(ConditionRow, { condition: r.cond, fields: fields, onChange: (patch) => updateCondition(r.id, patch), onRemove: () => removeCondition(r.id) }, r.id))) })), jsxs(Stack, { horizontal: true, tokens: { childrenGap: 8 }, styles: { root: { padding: '8px 0' } }, children: [jsx(DefaultButton, { iconProps: { iconName: 'Add' }, text: "Add condition", onClick: addCondition, disabled: fields.length === 0 }), rows.length > 0 && jsx(DefaultButton, { text: "Clear all", onClick: clearAll })] })] }));
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Shared helpers for the additive, default-off `fill`/`height` grid props (v1b).
|
|
1836
|
+
// `fill` makes a host fill its container (flex column at `height`, default 100%) and
|
|
1837
|
+
// scroll its list/cards region internally — so a grid dropped into a surface-kit
|
|
1838
|
+
// Section / Dialog / Panel scrolls inside the surface while the toolbar + footers
|
|
1839
|
+
// (and the surface's own footer) stay fixed. All default-off → unchanged when unset.
|
|
1840
|
+
//
|
|
1841
|
+
// Consumer contract: the container MUST have a resolved height (a flex/grid parent or
|
|
1842
|
+
// an explicit height) — `height: '100%'` against an auto-height parent renders nothing.
|
|
1843
|
+
// v1 limitation: the DetailsList column header scrolls with the body (not pinned via
|
|
1844
|
+
// ScrollablePane/Sticky); the surface-integration goal (grid scrolls so the surface
|
|
1845
|
+
// footer stays put) is still met.
|
|
1846
|
+
/** Stack root styles for a host that should fill its container. `undefined` when not filling. */
|
|
1847
|
+
function fillStackStyles(fill, height) {
|
|
1848
|
+
return fill ? { root: { height: height ?? '100%', display: 'flex', flexDirection: 'column', minHeight: 0 } } : undefined;
|
|
1849
|
+
}
|
|
1850
|
+
/** Wraps the scrollable region (list/cards) so it grows + scrolls inside a `fill` host; passthrough otherwise. */
|
|
1851
|
+
const FillRegion = ({ fill, children }) => fill ? jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: children }) : jsx(Fragment, { children: children });
|
|
1852
|
+
|
|
1853
|
+
/** Wraps a rendered DetailsList row as a file-drop target. */
|
|
1854
|
+
function RowFileDropTarget({ item, config, children, }) {
|
|
1855
|
+
const { isDragOver, dragProps } = useFileDrop({
|
|
1856
|
+
onDrop: (files) => config.onDrop(item, files),
|
|
1857
|
+
accept: config.accept,
|
|
1858
|
+
multiple: config.multiple,
|
|
1859
|
+
onReject: config.onReject ? (reason) => config.onReject(item, reason) : undefined,
|
|
1860
|
+
});
|
|
1861
|
+
return (
|
|
1862
|
+
// role="presentation" so the wrapper doesn't add a node to the grid's ARIA
|
|
1863
|
+
// row structure — the DetailsRow inside keeps role="row".
|
|
1864
|
+
jsx("div", { ...dragProps, role: "presentation", style: {
|
|
1865
|
+
outline: isDragOver ? '2px solid #0078d4' : '2px solid transparent',
|
|
1866
|
+
background: isDragOver ? 'rgba(0,120,212,0.06)' : undefined,
|
|
1867
|
+
transition: 'background 80ms ease-out, outline-color 80ms ease-out',
|
|
1868
|
+
}, children: children }));
|
|
1869
|
+
}
|
|
1870
|
+
// One-time dev warning: client-side filtering over a consumer-pre-sliced page is
|
|
1871
|
+
// inconsistent with pagination.totalItems. Fired once per process.
|
|
1872
|
+
let warnedFilterPaging = false;
|
|
1873
|
+
function mapSelectionMode$2(mode) {
|
|
1874
|
+
switch (mode) {
|
|
1875
|
+
case 'single':
|
|
1876
|
+
return SelectionMode.single;
|
|
1877
|
+
case 'multiple':
|
|
1878
|
+
return SelectionMode.multiple;
|
|
1879
|
+
case 'none':
|
|
1880
|
+
default:
|
|
1881
|
+
return SelectionMode.none;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Flat / editable grid host built on Fluent v8 `ShimmeredDetailsList`. Columns
|
|
1886
|
+
* come from the registry via `toDetailsListColumns`; inline edit-state from
|
|
1887
|
+
* `useEditState` (per-cell click-to-edit by default). Card / grouped / nested
|
|
1888
|
+
* layouts are separate hosts that reuse the same registry + render context.
|
|
1889
|
+
*/
|
|
1890
|
+
function DataGrid(props) {
|
|
1891
|
+
const { items, columns, selectionMode = 'none', onSelectionChanged, isLoading, pagination, onPageChange, aggregateItems, toolbar, onActiveItemChanged, compact, rowFileDrop, exportConfig, alternateRowColors, navigateTo, onRowNavigate, rowCommands, columnChooser, onColumnOrderChange, filterBuilder, onFilterChange, fill, height, } = props;
|
|
1892
|
+
const edit = useEditState();
|
|
1893
|
+
const { getKeyFn, ctx } = useGridContext(props, edit);
|
|
1894
|
+
const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
|
|
1895
|
+
// Null-safe row key: with `enableShimmer`, Fluent calls getKey on UNDEFINED
|
|
1896
|
+
// placeholder rows — never let a consumer's getKey (e.g. `r => r.key`) crash
|
|
1897
|
+
// on those during loading.
|
|
1898
|
+
const safeRowKey = (it, index) => it == null ? `__gridkit_shimmer_${index ?? 0}` : getKeyFn(it);
|
|
1899
|
+
// Fluent Selection so consumers can read selected rows via onSelectionChanged.
|
|
1900
|
+
const onSelectionChangedRef = useRef(onSelectionChanged);
|
|
1901
|
+
onSelectionChangedRef.current = onSelectionChanged;
|
|
1902
|
+
const selectionRef = useRef();
|
|
1903
|
+
if (!selectionRef.current) {
|
|
1904
|
+
selectionRef.current = new Selection({
|
|
1905
|
+
getKey: (it, index) => safeRowKey(it, index),
|
|
1906
|
+
onSelectionChanged: () => onSelectionChangedRef.current?.(selectionRef.current.getSelection()),
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
// Column chooser: the visible+ordered column keys (default all). Reconciles when
|
|
1910
|
+
// the `columns` prop changes — new keys appended (visible), removed keys dropped.
|
|
1911
|
+
const [visibleOrder, setVisibleOrder] = useState(() => columns.map((c) => c.key));
|
|
1912
|
+
useEffect(() => {
|
|
1913
|
+
setVisibleOrder((prev) => {
|
|
1914
|
+
const keys = new Set(columns.map((c) => c.key));
|
|
1915
|
+
const kept = prev.filter((k) => keys.has(k));
|
|
1916
|
+
const added = columns.map((c) => c.key).filter((k) => !prev.includes(k));
|
|
1917
|
+
const next = [...kept, ...added];
|
|
1918
|
+
return next.length === prev.length && next.every((k, i) => k === prev[i]) ? prev : next;
|
|
1919
|
+
});
|
|
1920
|
+
}, [columns]);
|
|
1921
|
+
const [columnChooserOpen, setColumnChooserOpen] = useState(false);
|
|
1922
|
+
const effectiveColumns = useMemo(() => (columnChooser ? orderVisibleColumns(columns, visibleOrder) : columns), [columnChooser, columns, visibleOrder]);
|
|
1923
|
+
// Filter builder: the applied model + the filtered row set. Filtering changes
|
|
1924
|
+
// the row SET (unlike the chooser's purely-visual column projection), so
|
|
1925
|
+
// effectiveItems feeds the list, aggregate footer, AND export scope together.
|
|
1926
|
+
const [filterModel, setFilterModel] = useState({ match: 'all', conditions: [] });
|
|
1927
|
+
const [filterBuilderOpen, setFilterBuilderOpen] = useState(false);
|
|
1928
|
+
const effectiveItems = useMemo(() => (filterBuilder ? applyFilters(items, filterModel) : items), [filterBuilder, items, filterModel]);
|
|
1929
|
+
const activeFilterCount = useMemo(() => filterModel.conditions.filter(isConditionComplete).length, [filterModel]);
|
|
1930
|
+
// Footer aggregate source: the displayed (filtered) page by default, or the full
|
|
1931
|
+
// `aggregateItems` dataset for cross-page grand totals — with the active filter
|
|
1932
|
+
// applied to it too, so the grand total reflects the filter across all pages.
|
|
1933
|
+
const aggregateSource = useMemo(() => aggregateItems ? (filterBuilder ? applyFilters(aggregateItems, filterModel) : aggregateItems) : effectiveItems, [aggregateItems, filterBuilder, filterModel, effectiveItems]);
|
|
1934
|
+
useEffect(() => {
|
|
1935
|
+
if (filterBuilder && pagination && !warnedFilterPaging && typeof console !== 'undefined') {
|
|
1936
|
+
warnedFilterPaging = true;
|
|
1937
|
+
console.warn('[grid-kit] filterBuilder filters the in-memory `items` (the current page when paginated), ' +
|
|
1938
|
+
'so the visible/footer/export counts can disagree with pagination.totalItems. For ' +
|
|
1939
|
+
'server-side filtering, read onFilterChange and re-query instead.');
|
|
1940
|
+
}
|
|
1941
|
+
}, [filterBuilder, pagination]);
|
|
1942
|
+
const dlColumns = useMemo(() => toDetailsListColumns(effectiveColumns, ctx), [effectiveColumns, ctx]);
|
|
1943
|
+
// Turnkey export. selectionRef.getSelection() is safe even when selectionMode
|
|
1944
|
+
// is 'none' (returns []). Scope: 'all' = every row; 'selected' = only the
|
|
1945
|
+
// selection (no-op when empty / not selectable); default = selection if any,
|
|
1946
|
+
// else all.
|
|
1947
|
+
// NOTE: export uses the FULL `columns`, independent of the column chooser's
|
|
1948
|
+
// on-screen visibility — you export the whole dataset; the export dialog has
|
|
1949
|
+
// its own column picker for narrowing the output. Rows, however, DO honor the
|
|
1950
|
+
// filter builder (`effectiveItems`) — you export what the filter shows.
|
|
1951
|
+
const exportFormats = exportConfig?.formats ?? ['csv', 'json'];
|
|
1952
|
+
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
|
1953
|
+
const scopedRows = useCallback(() => {
|
|
1954
|
+
const selected = (selectionRef.current?.getSelection() ?? []);
|
|
1955
|
+
if (exportConfig?.scope === 'all')
|
|
1956
|
+
return effectiveItems;
|
|
1957
|
+
if (exportConfig?.scope === 'selected')
|
|
1958
|
+
return selected;
|
|
1959
|
+
return selected.length > 0 ? selected : effectiveItems;
|
|
1960
|
+
}, [effectiveItems, exportConfig]);
|
|
1961
|
+
const doExport = useCallback((format) => {
|
|
1962
|
+
const rows = scopedRows();
|
|
1963
|
+
if (rows.length === 0)
|
|
1964
|
+
return;
|
|
1965
|
+
exportGrid(rows, columns, format, exportConfig?.filename);
|
|
1966
|
+
}, [columns, exportConfig, scopedRows]);
|
|
1967
|
+
const exportCommand = !exportConfig?.enabled
|
|
1968
|
+
? undefined
|
|
1969
|
+
: exportConfig.dialog
|
|
1970
|
+
? {
|
|
1971
|
+
// Dialog mode — a single "Export…" button opening the column/format picker.
|
|
1972
|
+
key: '__gridkit_export__',
|
|
1973
|
+
text: 'Export…',
|
|
1974
|
+
iconName: 'Download',
|
|
1975
|
+
onClick: () => setExportDialogOpen(true),
|
|
1976
|
+
}
|
|
1977
|
+
: {
|
|
1978
|
+
key: '__gridkit_export__',
|
|
1979
|
+
text: 'Export',
|
|
1980
|
+
iconName: 'Download',
|
|
1981
|
+
split: exportFormats.length > 1,
|
|
1982
|
+
onClick: () => doExport(exportFormats[0]),
|
|
1983
|
+
subMenuProps: exportFormats.length > 1
|
|
1984
|
+
? {
|
|
1985
|
+
items: exportFormats.map((f) => ({
|
|
1986
|
+
key: f,
|
|
1987
|
+
text: f.toUpperCase(),
|
|
1988
|
+
iconProps: { iconName: f === 'csv' ? 'ExcelDocument' : 'Code' },
|
|
1989
|
+
onClick: () => doExport(f),
|
|
1990
|
+
})),
|
|
1991
|
+
}
|
|
1992
|
+
: undefined,
|
|
1993
|
+
};
|
|
1994
|
+
// "Open" navigation command — navigates the selected row (else the active one).
|
|
1995
|
+
const activeItemRef = useRef(undefined);
|
|
1996
|
+
const openCommand = navigateTo
|
|
1997
|
+
? {
|
|
1998
|
+
key: '__gridkit_open__',
|
|
1999
|
+
text: 'Open',
|
|
2000
|
+
iconName: 'OpenInNewWindow',
|
|
2001
|
+
onClick: () => {
|
|
2002
|
+
const selected = (selectionRef.current?.getSelection() ?? []);
|
|
2003
|
+
const target = selected[0] ?? activeItemRef.current;
|
|
2004
|
+
if (!target)
|
|
2005
|
+
return;
|
|
2006
|
+
const nav = navigateTo(target);
|
|
2007
|
+
if (nav)
|
|
2008
|
+
void navigateToTarget(nav);
|
|
2009
|
+
},
|
|
2010
|
+
}
|
|
2011
|
+
: undefined;
|
|
2012
|
+
// "Columns" command — opens the column chooser panel.
|
|
2013
|
+
const columnsCommand = columnChooser
|
|
2014
|
+
? {
|
|
2015
|
+
key: '__gridkit_columns__',
|
|
2016
|
+
text: 'Columns',
|
|
2017
|
+
iconName: 'ColumnOptions',
|
|
2018
|
+
onClick: () => setColumnChooserOpen(true),
|
|
2019
|
+
}
|
|
2020
|
+
: undefined;
|
|
2021
|
+
// "Filters" command — opens the filter builder panel (shows the active count).
|
|
2022
|
+
const filtersCommand = filterBuilder
|
|
2023
|
+
? {
|
|
2024
|
+
key: '__gridkit_filters__',
|
|
2025
|
+
text: activeFilterCount ? `Filters (${activeFilterCount})` : 'Filters',
|
|
2026
|
+
iconName: 'Filter',
|
|
2027
|
+
onClick: () => setFilterBuilderOpen(true),
|
|
2028
|
+
}
|
|
2029
|
+
: undefined;
|
|
2030
|
+
const injectedCommands = [
|
|
2031
|
+
exportCommand,
|
|
2032
|
+
openCommand,
|
|
2033
|
+
columnsCommand,
|
|
2034
|
+
filtersCommand,
|
|
2035
|
+
].filter(Boolean);
|
|
2036
|
+
const effectiveToolbar = toolbar || injectedCommands.length
|
|
2037
|
+
? { ...toolbar, commands: [...injectedCommands, ...(toolbar?.commands ?? [])] }
|
|
2038
|
+
: undefined;
|
|
2039
|
+
// Compose alternate-row tint + whole-row file-drop into ONE onRenderRow (both
|
|
2040
|
+
// want it). Return undefined when neither is set so Fluent uses its optimized
|
|
2041
|
+
// default-row path. Tint by passing fresh `styles` to defaultRender — never
|
|
2042
|
+
// mutate rowProps.styles (it is optional and may be a style function).
|
|
2043
|
+
const onRenderRow = useMemo(() => rowFileDrop || alternateRowColors
|
|
2044
|
+
? (rowProps, defaultRender) => {
|
|
2045
|
+
if (!rowProps || !defaultRender)
|
|
2046
|
+
return null;
|
|
2047
|
+
const rendered = alternateRowColors
|
|
2048
|
+
? defaultRender({
|
|
2049
|
+
...rowProps,
|
|
2050
|
+
styles: {
|
|
2051
|
+
root: {
|
|
2052
|
+
backgroundColor: rowProps.itemIndex % 2 === 0 ? alternateRowColors.even : alternateRowColors.odd,
|
|
2053
|
+
},
|
|
2054
|
+
},
|
|
2055
|
+
})
|
|
2056
|
+
: defaultRender(rowProps);
|
|
2057
|
+
return rowFileDrop ? (jsx(RowFileDropTarget, { item: rowProps.item, config: rowFileDrop, children: rendered })) : (rendered);
|
|
2058
|
+
}
|
|
2059
|
+
: undefined, [rowFileDrop, alternateRowColors]);
|
|
2060
|
+
return (jsxs(Stack, { styles: fillStackStyles(fill, height), children: [effectiveToolbar && jsx(GridToolbar, { config: effectiveToolbar }), jsx(FillRegion, { fill: fill, children: jsx(ShimmeredDetailsList, { items: effectiveItems, columns: dlColumns, selectionMode: mapSelectionMode$2(selectionMode), selection: selectionMode !== 'none' ? selectionRef.current : undefined, enableShimmer: isLoading, layoutMode: DetailsListLayoutMode.justified, compact: compact, getKey: (it, index) => safeRowKey(it, index), setKey: "grid-kit", onRenderRow: onRenderRow, onActiveItemChanged: (item, index) => {
|
|
2061
|
+
activeItemRef.current = item;
|
|
2062
|
+
onActiveItemChanged?.(item, index);
|
|
2063
|
+
}, onItemInvoked: onRowNavigate ? (item) => onRowNavigate(item) : undefined, onItemContextMenu: onItemContextMenu, styles: getDetailsListStyles(), ariaLabelForShimmer: "Loading data", ariaLabelForGrid: "Grid" }) }), contextMenuElement, effectiveColumns.some((c) => c.aggregate) && (jsx(GridAggregateFooter, { items: aggregateSource, columns: effectiveColumns, leadingSpacer: selectionMode !== 'none' ? 48 : 0 })), pagination && jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange }), exportConfig?.dialog && (jsx(GridExportDialog, { isOpen: exportDialogOpen, onClose: () => setExportDialogOpen(false), items: exportDialogOpen ? scopedRows() : [], columns: columns, defaultFilename: exportConfig.filename, formats: exportFormats })), columnChooser && (jsx(GridColumnChooser, { isOpen: columnChooserOpen, onClose: () => setColumnChooserOpen(false), columns: columns, visibleOrder: visibleOrder, onApply: (order) => {
|
|
2064
|
+
setVisibleOrder(order);
|
|
2065
|
+
onColumnOrderChange?.(order);
|
|
2066
|
+
} })), filterBuilder && (jsx(GridFilterBuilder, { isOpen: filterBuilderOpen, onClose: () => setFilterBuilderOpen(false), columns: columns, model: filterModel, onApply: (m) => {
|
|
2067
|
+
setFilterModel(m);
|
|
2068
|
+
onFilterChange?.(m);
|
|
2069
|
+
} }))] }));
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* Read-only grid: `<DataGrid>` with selection off, editing off, and command-bar
|
|
2074
|
+
* commands stripped (search is kept). Mirrors the Grid Customizer's "Read-Only
|
|
2075
|
+
* Grid" type — a presentation preset, no extra config.
|
|
2076
|
+
*/
|
|
2077
|
+
function ReadOnlyGrid(props) {
|
|
2078
|
+
const toolbar = props.toolbar
|
|
2079
|
+
? { showSearch: props.toolbar.showSearch, searchPlaceholder: props.toolbar.searchPlaceholder, onSearch: props.toolbar.onSearch }
|
|
2080
|
+
: undefined;
|
|
2081
|
+
return jsx(DataGrid, { ...props, selectionMode: "none", editable: false, toolbar: toolbar });
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// Shared card-layout primitives for the card hosts (`<CardGrid>` flat cards +
|
|
2085
|
+
// `<NestedCardParent>` expandable parent cards). The derivation is a PURE function
|
|
2086
|
+
// (no hooks) so it's unit-testable without a DOM; the mergeStyles classes are shared
|
|
2087
|
+
// so the two hosts can't drift apart visually.
|
|
2088
|
+
const cardClass = mergeStyles({
|
|
2089
|
+
border: '1px solid #edebe9',
|
|
2090
|
+
borderRadius: 4,
|
|
2091
|
+
padding: 12,
|
|
2092
|
+
background: '#fff',
|
|
2093
|
+
display: 'flex',
|
|
2094
|
+
flexDirection: 'column',
|
|
2095
|
+
gap: 6,
|
|
2096
|
+
boxShadow: '0 1px 2px rgba(0,0,0,0.06)',
|
|
2097
|
+
});
|
|
2098
|
+
const cardImageClass = mergeStyles({
|
|
2099
|
+
width: '100%',
|
|
2100
|
+
height: 96,
|
|
2101
|
+
objectFit: 'cover',
|
|
2102
|
+
borderRadius: 2,
|
|
2103
|
+
marginBottom: 4,
|
|
2104
|
+
});
|
|
2105
|
+
const bodyRowClass = mergeStyles({
|
|
2106
|
+
display: 'flex',
|
|
2107
|
+
justifyContent: 'space-between',
|
|
2108
|
+
alignItems: 'center',
|
|
2109
|
+
gap: 8,
|
|
2110
|
+
fontSize: 13,
|
|
2111
|
+
});
|
|
2112
|
+
/**
|
|
2113
|
+
* PURE derivation of a card layout from columns + config — no React hooks, so it's
|
|
2114
|
+
* directly unit-testable. Hosts wrap the call in their own `useMemo`. titleField
|
|
2115
|
+
* defaults to the first column; bodyColumns defaults to all columns except the
|
|
2116
|
+
* title/subtitle/image fields (explicit `bodyFields` are honored verbatim).
|
|
2117
|
+
*/
|
|
2118
|
+
function resolveCardLayout(columns, card) {
|
|
2119
|
+
const cardsPerRow = card?.cardsPerRow ?? 3;
|
|
2120
|
+
const titleField = card?.titleField ?? columns[0]?.fieldName;
|
|
2121
|
+
const { subtitleField, imageField, cardHeight } = card ?? {};
|
|
2122
|
+
const byField = new Map();
|
|
2123
|
+
columns.forEach((c) => byField.set(c.fieldName, c));
|
|
2124
|
+
const explicit = card?.bodyFields;
|
|
2125
|
+
const bodyColumns = explicit
|
|
2126
|
+
? explicit.map((f) => byField.get(f)).filter(Boolean)
|
|
2127
|
+
: columns.filter((c) => !new Set([titleField, subtitleField, imageField].filter(Boolean)).has(c.fieldName));
|
|
2128
|
+
return { cardsPerRow, titleField, subtitleField, imageField, cardHeight, byField, bodyColumns };
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/**
|
|
2132
|
+
* Card-view grid host. Renders each row as a card; every field value goes
|
|
2133
|
+
* through the SAME cell registry as `<DataGrid>` (so status badges, currency,
|
|
2134
|
+
* ratings, etc. look identical). Mirrors the Grid Customizer's "Card List" type.
|
|
2135
|
+
*
|
|
2136
|
+
* Selection is checkbox-based (manual Set), independent of Fluent's DetailsList
|
|
2137
|
+
* Selection.
|
|
2138
|
+
*/
|
|
2139
|
+
function CardGrid(props) {
|
|
2140
|
+
const { items, columns, card, selectionMode = 'none', onSelectionChanged, toolbar, pagination, onPageChange, onActiveItemChanged, fill, height, } = props;
|
|
2141
|
+
const edit = useEditState();
|
|
2142
|
+
const { getKeyFn, ctx } = useGridContext(props, edit);
|
|
2143
|
+
// Card layout derivation is shared (and unit-tested) via resolveCardLayout —
|
|
2144
|
+
// identical to the prior inline derivation, so rendered output is unchanged.
|
|
2145
|
+
const { cardsPerRow, titleField, subtitleField, imageField, cardHeight, byField, bodyColumns } = useMemo(() => resolveCardLayout(columns, card), [columns, card]);
|
|
2146
|
+
const [selected, setSelected] = useState(new Set());
|
|
2147
|
+
const renderField = (col, item) => {
|
|
2148
|
+
if (!col)
|
|
2149
|
+
return null;
|
|
2150
|
+
const cellProps = buildCellProps(col, item, ctx);
|
|
2151
|
+
const Renderer = ctx.registry.resolve(col, false); // cards render read-only
|
|
2152
|
+
return jsx(Renderer, { ...cellProps });
|
|
2153
|
+
};
|
|
2154
|
+
const rawValue = (item, field) => {
|
|
2155
|
+
if (!field)
|
|
2156
|
+
return '';
|
|
2157
|
+
const v = item[field];
|
|
2158
|
+
return v == null ? '' : String(v);
|
|
2159
|
+
};
|
|
2160
|
+
const toggleSelect = (item, checked) => {
|
|
2161
|
+
const key = getKeyFn(item);
|
|
2162
|
+
setSelected((prev) => {
|
|
2163
|
+
const next = new Set(selectionMode === 'single' ? [] : prev);
|
|
2164
|
+
if (checked)
|
|
2165
|
+
next.add(key);
|
|
2166
|
+
else
|
|
2167
|
+
next.delete(key);
|
|
2168
|
+
onSelectionChanged?.(items.filter((it) => next.has(getKeyFn(it))));
|
|
2169
|
+
return next;
|
|
2170
|
+
});
|
|
2171
|
+
};
|
|
2172
|
+
return (jsxs(Stack, { styles: fillStackStyles(fill, height), children: [toolbar && jsx(GridToolbar, { config: toolbar }), jsx(FillRegion, { fill: fill, children: jsx("div", { style: {
|
|
2173
|
+
display: 'grid',
|
|
2174
|
+
gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))`,
|
|
2175
|
+
gap: 12,
|
|
2176
|
+
paddingTop: 4,
|
|
2177
|
+
}, children: items.map((item) => {
|
|
2178
|
+
const key = getKeyFn(item);
|
|
2179
|
+
return (jsxs("div", { className: cardClass, style: cardHeight ? { height: cardHeight } : undefined, onClick: () => onActiveItemChanged?.(item), children: [jsxs(Stack, { horizontal: true, horizontalAlign: "space-between", verticalAlign: "start", children: [jsx(Text, { variant: "mediumPlus", styles: { root: { fontWeight: 600 } }, children: renderField(byField.get(titleField ?? ''), item) ?? rawValue(item, titleField) }), selectionMode !== 'none' && (
|
|
2180
|
+
// Stop the click from bubbling to the card's onActiveItemChanged.
|
|
2181
|
+
jsx("span", { onClick: (e) => e.stopPropagation(), children: jsx(Checkbox, { checked: selected.has(key), onChange: (_, checked) => toggleSelect(item, checked), ariaLabel: "Select card" }) }))] }), imageField && rawValue(item, imageField) && (jsx("img", { className: cardImageClass, src: rawValue(item, imageField), alt: "" })), subtitleField && (jsx(Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: rawValue(item, subtitleField) })), bodyColumns.map((col) => (jsxs("div", { className: bodyRowClass, children: [jsx(Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: col.name }), jsx("span", { children: renderField(col, item) })] }, col.key)))] }, key));
|
|
2182
|
+
}) }) }), pagination && jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange })] }));
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
/**
|
|
2186
|
+
* Bucket rows by `group.groupBy` into a contiguous, ordered array plus the
|
|
2187
|
+
* Fluent v8 `IGroup[]` describing each bucket's range. Fluent's grouped
|
|
2188
|
+
* DetailsList requires the items array to be ordered so each group's
|
|
2189
|
+
* `startIndex`/`count` is contiguous — this guarantees that. Pure + exported so
|
|
2190
|
+
* it's unit-testable and reusable.
|
|
2191
|
+
*/
|
|
2192
|
+
function buildGroups(items, group) {
|
|
2193
|
+
const buckets = new Map();
|
|
2194
|
+
for (const it of items) {
|
|
2195
|
+
const v = it[group.groupBy];
|
|
2196
|
+
const k = v == null ? '(Blank)' : String(v);
|
|
2197
|
+
let arr = buckets.get(k);
|
|
2198
|
+
if (!arr) {
|
|
2199
|
+
arr = [];
|
|
2200
|
+
buckets.set(k, arr);
|
|
2201
|
+
}
|
|
2202
|
+
arr.push(it);
|
|
2203
|
+
}
|
|
2204
|
+
const orderedItems = [];
|
|
2205
|
+
const groups = [];
|
|
2206
|
+
const showCount = group.showCount !== false;
|
|
2207
|
+
let start = 0;
|
|
2208
|
+
for (const [k, rows] of buckets) {
|
|
2209
|
+
const label = group.getGroupLabel ? group.getGroupLabel(rows[0][group.groupBy], rows) : k;
|
|
2210
|
+
groups.push({
|
|
2211
|
+
key: k,
|
|
2212
|
+
name: showCount ? `${label} (${rows.length})` : label,
|
|
2213
|
+
startIndex: start,
|
|
2214
|
+
count: rows.length,
|
|
2215
|
+
level: 0,
|
|
2216
|
+
isCollapsed: group.collapsedByDefault ?? false,
|
|
2217
|
+
});
|
|
2218
|
+
orderedItems.push(...rows);
|
|
2219
|
+
start += rows.length;
|
|
2220
|
+
}
|
|
2221
|
+
return { orderedItems, groups };
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function mapSelectionMode$1(mode) {
|
|
2225
|
+
return mode === 'multiple' ? SelectionMode.multiple : mode === 'single' ? SelectionMode.single : SelectionMode.none;
|
|
2226
|
+
}
|
|
2227
|
+
// Approx. widths used to left-align a group subtotal under its rows (column
|
|
2228
|
+
// alignment under justified layout is approximate — see GridAggregateFooter).
|
|
2229
|
+
const SELECTION_COLUMN_WIDTH = 48;
|
|
2230
|
+
const GROUP_CHEVRON_WIDTH = 36;
|
|
2231
|
+
/**
|
|
2232
|
+
* Grouped grid host. Buckets rows by `group.groupBy` into a contiguous,
|
|
2233
|
+
* ordered list and renders a Fluent v8 grouped `DetailsList` (collapsible
|
|
2234
|
+
* groups + count badges), reusing the same columns/registry as `<DataGrid>`.
|
|
2235
|
+
*/
|
|
2236
|
+
function GroupedGrid(props) {
|
|
2237
|
+
const { items, columns, group, selectionMode = 'none', isLoading, pagination, onPageChange, aggregateItems, toolbar, compact, alternateRowColors, fill, height, rowCommands } = props;
|
|
2238
|
+
const edit = useEditState();
|
|
2239
|
+
const { ctx } = useGridContext(props, edit);
|
|
2240
|
+
const dlColumns = useMemo(() => toDetailsListColumns(columns, ctx), [columns, ctx]);
|
|
2241
|
+
// Per-row right-click menu (DetailsList path, same as DataGrid/NestedInline). No-op when
|
|
2242
|
+
// rowCommands is unset (onItemContextMenu is undefined → no handler attached).
|
|
2243
|
+
const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
|
|
2244
|
+
// Bucket by groupBy → ordered items + IGroup[] (DetailsList needs contiguous
|
|
2245
|
+
// group ranges). See buildGroups (pure + unit-tested).
|
|
2246
|
+
const { orderedItems, groups } = useMemo(() => buildGroups(items, group), [items, group]);
|
|
2247
|
+
const hasAggregate = columns.some((c) => c.aggregate);
|
|
2248
|
+
// Per-group subtotals: aggregate each group's slice (keyed by group key).
|
|
2249
|
+
const groupAggregates = useMemo(() => group.showGroupAggregates && hasAggregate
|
|
2250
|
+
? computeGroupAggregates(orderedItems, groups.map((g) => ({ key: g.key, startIndex: g.startIndex, count: g.count })), columns)
|
|
2251
|
+
: {}, [group.showGroupAggregates, hasAggregate, orderedItems, groups, columns]);
|
|
2252
|
+
// Overall footer source: all displayed groups by default, or the full
|
|
2253
|
+
// `aggregateItems` dataset for cross-page grand totals. Note: per-group subtotals
|
|
2254
|
+
// always aggregate the displayed (page) rows of each group — only the overall
|
|
2255
|
+
// footer goes cross-page.
|
|
2256
|
+
const footerItems = aggregateItems ?? orderedItems;
|
|
2257
|
+
// Approx. left offset for a group subtotal: selection checkbox + group-expand chevron.
|
|
2258
|
+
const groupLeadingSpacer = (selectionMode !== 'none' ? SELECTION_COLUMN_WIDTH : 0) + GROUP_CHEVRON_WIDTH;
|
|
2259
|
+
// Alt-row tint (file-drop isn't supported on grouped grids). Striping is by the
|
|
2260
|
+
// flattened row index across groups.
|
|
2261
|
+
const onRenderRow = useMemo(() => alternateRowColors
|
|
2262
|
+
? (rowProps, defaultRender) => {
|
|
2263
|
+
if (!rowProps || !defaultRender)
|
|
2264
|
+
return null;
|
|
2265
|
+
return defaultRender({
|
|
2266
|
+
...rowProps,
|
|
2267
|
+
styles: {
|
|
2268
|
+
root: {
|
|
2269
|
+
backgroundColor: rowProps.itemIndex % 2 === 0 ? alternateRowColors.even : alternateRowColors.odd,
|
|
2270
|
+
},
|
|
2271
|
+
},
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
: undefined, [alternateRowColors]);
|
|
2275
|
+
return (jsxs(Stack, { styles: fillStackStyles(fill, height), children: [toolbar && jsx(GridToolbar, { config: toolbar }), jsx(FillRegion, { fill: fill, children: jsx(ShimmeredDetailsList, { items: orderedItems, columns: dlColumns, groups: groups, selectionMode: mapSelectionMode$1(selectionMode), layoutMode: DetailsListLayoutMode.justified, compact: compact, setKey: "grid-kit-grouped", onRenderRow: onRenderRow, onItemContextMenu: onItemContextMenu, styles: getDetailsListStyles(), ariaLabelForGrid: "Grouped grid", groupProps: {
|
|
2276
|
+
showEmptyGroups: false,
|
|
2277
|
+
// Render a subtotal under each EXPANDED group. Fluent v8 calls the group
|
|
2278
|
+
// footer renderer even for collapsed groups, so guard on isCollapsed —
|
|
2279
|
+
// otherwise a subtotal floats under a collapsed header whose rows are hidden.
|
|
2280
|
+
onRenderFooter: group.showGroupAggregates && hasAggregate
|
|
2281
|
+
? (gprops) => gprops?.group && !gprops.group.isCollapsed ? (jsx(AggregateRow, { columns: columns, aggregates: groupAggregates[gprops.group.key] ?? {}, leadingSpacer: groupLeadingSpacer, background: "#f3f2f1" })) : null
|
|
2282
|
+
: undefined,
|
|
2283
|
+
}, enableShimmer: isLoading }) }), contextMenuElement, hasAggregate && (jsx(GridAggregateFooter, { items: footerItems, columns: columns, leadingSpacer: selectionMode !== 'none' ? 48 : 0 })), pagination && jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange })] }));
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/**
|
|
2287
|
+
* Lazily resolves a nested grid's children per parent row. `getChildren` may
|
|
2288
|
+
* return a sync array or a promise; results are cached by row key. grid-kit
|
|
2289
|
+
* never fetches — this just normalizes the consumer-supplied resolver and tracks
|
|
2290
|
+
* loading. Call `ensure(key, parent)` on demand (row expand / panel open / hover).
|
|
2291
|
+
*/
|
|
2292
|
+
function useChildren(getChildren) {
|
|
2293
|
+
const [cache, setCache] = useState({});
|
|
2294
|
+
const cacheRef = useRef(cache);
|
|
2295
|
+
cacheRef.current = cache;
|
|
2296
|
+
// Guard against setState after unmount (e.g. host swapped while a child fetch
|
|
2297
|
+
// is in flight) — mirrors NestedTriggerCell / DetailPaneChildren.
|
|
2298
|
+
const mounted = useRef(true);
|
|
2299
|
+
useEffect(() => {
|
|
2300
|
+
mounted.current = true;
|
|
2301
|
+
return () => {
|
|
2302
|
+
mounted.current = false;
|
|
2303
|
+
};
|
|
2304
|
+
}, []);
|
|
2305
|
+
const ensure = useCallback((key, parent) => {
|
|
2306
|
+
if (cacheRef.current[key])
|
|
2307
|
+
return;
|
|
2308
|
+
const result = getChildren(parent);
|
|
2309
|
+
if (Array.isArray(result)) {
|
|
2310
|
+
setCache((p) => ({ ...p, [key]: { loading: false, rows: result } }));
|
|
2311
|
+
}
|
|
2312
|
+
else {
|
|
2313
|
+
setCache((p) => ({ ...p, [key]: { loading: true, rows: [] } }));
|
|
2314
|
+
Promise.resolve(result).then((rows) => {
|
|
2315
|
+
if (mounted.current)
|
|
2316
|
+
setCache((p) => ({ ...p, [key]: { loading: false, rows } }));
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
}, [getChildren]);
|
|
2320
|
+
const get = useCallback((key) => cache[key], [cache]);
|
|
2321
|
+
return { get, ensure };
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// Leading control-column widths the parent footer must skip to line up its cells
|
|
2325
|
+
// with the data columns: the selection checkbox (Fluent default ~48px, present
|
|
2326
|
+
// only when the parent grid is selectable) and the always-present expand chevron.
|
|
2327
|
+
const SELECTION_CHECKBOX_WIDTH = 48;
|
|
2328
|
+
const EXPAND_CHEVRON_WIDTH = 32;
|
|
2329
|
+
function mapSelectionMode(mode) {
|
|
2330
|
+
return mode === 'multiple' ? SelectionMode.multiple : mode === 'single' ? SelectionMode.single : SelectionMode.none;
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Inline nested grid: a chevron column expands each parent row to reveal its
|
|
2334
|
+
* child grid indented beneath (Fluent `onRenderRow`). Children resolve lazily on
|
|
2335
|
+
* first expand.
|
|
2336
|
+
*
|
|
2337
|
+
* Known limitation: an expanded row's height varies with its child grid, which
|
|
2338
|
+
* defeats Fluent v8 DetailsList row-height virtualization — fine for typical
|
|
2339
|
+
* subgrid sizes; for very large parent lists prefer side-panel / hover-callout.
|
|
2340
|
+
*/
|
|
2341
|
+
function NestedInline(props) {
|
|
2342
|
+
const { parentProps, ctx, getKeyFn } = props;
|
|
2343
|
+
const { items, columns, nested, selectionMode, isLoading, compact, alternateRowColors, pagination, onPageChange, aggregateItems, onSelectionChanged, rowCommands, } = parentProps;
|
|
2344
|
+
const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
|
|
2345
|
+
const [expanded, setExpanded] = useState(new Set());
|
|
2346
|
+
const { get, ensure } = useChildren(nested.getChildren);
|
|
2347
|
+
// Forward parent-row selection to the consumer (DataGrid does this; inline did
|
|
2348
|
+
// not). A ref keeps the once-created Selection's callback from going stale when
|
|
2349
|
+
// the prop changes (mirrors DataGrid's onSelectionChangedRef).
|
|
2350
|
+
const onSelectionChangedRef = useRef(onSelectionChanged);
|
|
2351
|
+
onSelectionChangedRef.current = onSelectionChanged;
|
|
2352
|
+
// 'requires-parent' gating: a parent's child grid is only selectable while the
|
|
2353
|
+
// parent row is selected. Needs a managed parent Selection (the parent grid
|
|
2354
|
+
// must be selectable). Tracks selected parent keys to re-gate children on change.
|
|
2355
|
+
const gating = nested.childSelectionGating ?? 'independent';
|
|
2356
|
+
const parentSelectable = mapSelectionMode(selectionMode) !== SelectionMode.none;
|
|
2357
|
+
const [selectedParentKeys, setSelectedParentKeys] = useState(new Set());
|
|
2358
|
+
const selectionRef = useRef();
|
|
2359
|
+
if (!selectionRef.current) {
|
|
2360
|
+
selectionRef.current = new Selection({
|
|
2361
|
+
getKey: (it, index) => it == null ? `__shimmer_${index ?? 0}` : getKeyFn(it),
|
|
2362
|
+
onSelectionChanged: () => {
|
|
2363
|
+
const sel = selectionRef.current.getSelection();
|
|
2364
|
+
setSelectedParentKeys(new Set(sel.map((it) => getKeyFn(it))));
|
|
2365
|
+
onSelectionChangedRef.current?.(sel);
|
|
2366
|
+
},
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
// Surface the silent misconfiguration: gating needs a selectable parent grid.
|
|
2370
|
+
React.useEffect(() => {
|
|
2371
|
+
if (gating === 'requires-parent' && !parentSelectable && typeof console !== 'undefined') {
|
|
2372
|
+
console.warn("[grid-kit] childSelectionGating 'requires-parent' has no effect: the parent grid is not selectable. Set the nested grid's selectionMode to 'single' or 'multiple'.");
|
|
2373
|
+
}
|
|
2374
|
+
}, [gating, parentSelectable]);
|
|
2375
|
+
const toggle = useCallback((key, item) => {
|
|
2376
|
+
setExpanded((prev) => {
|
|
2377
|
+
const next = new Set(prev);
|
|
2378
|
+
if (next.has(key))
|
|
2379
|
+
next.delete(key);
|
|
2380
|
+
else {
|
|
2381
|
+
next.add(key);
|
|
2382
|
+
ensure(key, item);
|
|
2383
|
+
}
|
|
2384
|
+
return next;
|
|
2385
|
+
});
|
|
2386
|
+
}, [ensure]);
|
|
2387
|
+
const dlColumns = useMemo(() => {
|
|
2388
|
+
const chevron = {
|
|
2389
|
+
key: '__expand',
|
|
2390
|
+
name: '',
|
|
2391
|
+
fieldName: '__expand',
|
|
2392
|
+
minWidth: 32,
|
|
2393
|
+
maxWidth: 32,
|
|
2394
|
+
isResizable: false,
|
|
2395
|
+
onRender: (item) => {
|
|
2396
|
+
if (!item)
|
|
2397
|
+
return null;
|
|
2398
|
+
const key = getKeyFn(item);
|
|
2399
|
+
const open = expanded.has(key);
|
|
2400
|
+
return (jsx(IconButton, { iconProps: { iconName: open ? 'ChevronDown' : 'ChevronRight' }, ariaLabel: open ? 'Collapse' : 'Expand', styles: { root: { height: 28, width: 28 } }, onClick: (e) => {
|
|
2401
|
+
e.stopPropagation();
|
|
2402
|
+
toggle(key, item);
|
|
2403
|
+
} }));
|
|
2404
|
+
},
|
|
2405
|
+
};
|
|
2406
|
+
return [chevron, ...toDetailsListColumns(columns, ctx)];
|
|
2407
|
+
}, [columns, ctx, expanded, getKeyFn, toggle]);
|
|
2408
|
+
const onRenderRow = useCallback((rowProps, defaultRender) => {
|
|
2409
|
+
if (!rowProps || !defaultRender)
|
|
2410
|
+
return null;
|
|
2411
|
+
const parent = rowProps.item;
|
|
2412
|
+
const key = getKeyFn(parent);
|
|
2413
|
+
// Tint the parent row only (not the expansion wrapper) — pass fresh styles.
|
|
2414
|
+
const row = alternateRowColors
|
|
2415
|
+
? defaultRender({
|
|
2416
|
+
...rowProps,
|
|
2417
|
+
styles: {
|
|
2418
|
+
root: {
|
|
2419
|
+
backgroundColor: rowProps.itemIndex % 2 === 0 ? alternateRowColors.even : alternateRowColors.odd,
|
|
2420
|
+
},
|
|
2421
|
+
},
|
|
2422
|
+
})
|
|
2423
|
+
: defaultRender(rowProps);
|
|
2424
|
+
if (!expanded.has(key))
|
|
2425
|
+
return row;
|
|
2426
|
+
const entry = get(key);
|
|
2427
|
+
// requires-parent: child selectable only while THIS parent row is selected.
|
|
2428
|
+
const childMode = gating === 'requires-parent' && !selectedParentKeys.has(key) ? 'none' : nested.childSelectionMode;
|
|
2429
|
+
return (jsxs("div", { children: [row, jsx("div", { onContextMenu: (e) => e.stopPropagation(), style: { padding: '8px 8px 8px 48px', background: '#faf9f8', borderBottom: '1px solid #edebe9' }, children: !entry || entry.loading ? (jsx(Spinner, { label: "Loading related records\u2026" })) : (jsx(DataGrid, { items: entry.rows, columns: nested.childColumns, registry: nested.childRegistry, compact: true, selectionMode: childMode, onSelectionChanged: nested.onChildSelectionChanged
|
|
2430
|
+
? (sel) => nested.onChildSelectionChanged(parent, sel)
|
|
2431
|
+
: undefined, editable: nested.childEditable, editTrigger: nested.childEditTrigger, onValueChange: nested.onChildValueChange
|
|
2432
|
+
? (rk, fn, v, ov) => nested.onChildValueChange(parent, rk, fn, v, ov)
|
|
2433
|
+
: undefined })) })] }));
|
|
2434
|
+
}, [expanded, get, getKeyFn, nested, alternateRowColors, gating, selectedParentKeys]);
|
|
2435
|
+
// Parent-level footers (mirrors DataGrid): aggregate row when any parent column
|
|
2436
|
+
// declares an `aggregate`, then the pager. Aggregates over `aggregateItems`
|
|
2437
|
+
// (cross-page grand totals) when given, else the displayed parent rows. The
|
|
2438
|
+
// footer cells skip the selection checkbox + expand chevron so they line up
|
|
2439
|
+
// with the data columns.
|
|
2440
|
+
const hasAggregate = columns.some((c) => c.aggregate);
|
|
2441
|
+
const footerLeadingSpacer = (parentSelectable ? SELECTION_CHECKBOX_WIDTH : 0) + EXPAND_CHEVRON_WIDTH;
|
|
2442
|
+
return (jsxs(Fragment, { children: [jsx(ShimmeredDetailsList, { items: items, columns: dlColumns, onRenderRow: onRenderRow, selectionMode: mapSelectionMode(selectionMode), selection: parentSelectable ? selectionRef.current : undefined, enableShimmer: isLoading, layoutMode: DetailsListLayoutMode.justified, compact: compact, setKey: "grid-kit-nested-inline", getKey: (it, index) => (it == null ? `__shimmer_${index ?? 0}` : getKeyFn(it)), onItemContextMenu: onItemContextMenu, styles: getDetailsListStyles(), ariaLabelForGrid: "Nested grid" }), contextMenuElement, hasAggregate && (jsx(GridAggregateFooter, { items: aggregateItems ?? items, columns: columns, leadingSpacer: footerLeadingSpacer })), pagination && jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange })] }));
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* Card-parent nested grid (`parentLayout: 'cards'`): each parent row renders as a
|
|
2447
|
+
* card that expands IN PLACE to reveal its child grid below the card fields. Reuses
|
|
2448
|
+
* the shared card layout (`resolveCardLayout` + classes) for the card body and the
|
|
2449
|
+
* lazy `useChildren` hook for child resolution (grid-kit never fetches). v8-only.
|
|
2450
|
+
*
|
|
2451
|
+
* `nested.mode` does NOT apply to card parents — `<NestedGrid>` routes here on
|
|
2452
|
+
* `parentLayout === 'cards'` BEFORE the mode discriminator, so the side-panel /
|
|
2453
|
+
* hover-callout trigger machinery is bypassed.
|
|
2454
|
+
*
|
|
2455
|
+
* v1 limitation: `childSelectionGating: 'requires-parent'` is unsupported here — cards
|
|
2456
|
+
* use a manual checkbox `Set`, not a Fluent DetailsList `Selection` to gate against —
|
|
2457
|
+
* so it's treated as `'independent'` with a one-time `console.warn`.
|
|
2458
|
+
*/
|
|
2459
|
+
function NestedCardParent(props) {
|
|
2460
|
+
const { parentProps, ctx, getKeyFn } = props;
|
|
2461
|
+
const { items, columns, nested, selectionMode = 'none', onSelectionChanged, toolbar, pagination, onPageChange, aggregateItems, fill, height, rowCommands, } = parentProps;
|
|
2462
|
+
// Per-row right-click menu (parity with DataGrid/NestedInline). For these raw-div
|
|
2463
|
+
// cards we invoke the hook fn from a native onContextMenu and suppress the browser
|
|
2464
|
+
// menu with e.preventDefault() ourselves (the hook's `return false` only matters to
|
|
2465
|
+
// Fluent's DetailsList onItemContextMenu contract).
|
|
2466
|
+
const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
|
|
2467
|
+
const { cardsPerRow, titleField, subtitleField, imageField, cardHeight, byField, bodyColumns } = useMemo(() => resolveCardLayout(columns, nested.parentCard), [columns, nested.parentCard]);
|
|
2468
|
+
// Parent footers (parity with NestedInline / CardGrid): aggregate row when any
|
|
2469
|
+
// parent column declares an `aggregate`, then the pager. No DetailsList leading
|
|
2470
|
+
// control columns on a card grid, so the aggregate footer has no leading spacer.
|
|
2471
|
+
const hasAggregate = columns.some((c) => c.aggregate);
|
|
2472
|
+
const [selected, setSelected] = useState(new Set());
|
|
2473
|
+
const [expanded, setExpanded] = useState(new Set());
|
|
2474
|
+
const { get, ensure } = useChildren(nested.getChildren);
|
|
2475
|
+
// requires-parent gating needs a Fluent Selection over the parent rows; cards have
|
|
2476
|
+
// none, so surface the unsupported config and fall back to independent selection.
|
|
2477
|
+
React.useEffect(() => {
|
|
2478
|
+
if (nested.childSelectionGating === 'requires-parent' && typeof console !== 'undefined') {
|
|
2479
|
+
console.warn("[grid-kit] childSelectionGating 'requires-parent' is unsupported for card parents (cards have no Fluent Selection); treating as 'independent'.");
|
|
2480
|
+
}
|
|
2481
|
+
}, [nested.childSelectionGating]);
|
|
2482
|
+
const renderField = (col, item) => {
|
|
2483
|
+
if (!col)
|
|
2484
|
+
return null;
|
|
2485
|
+
const cellProps = buildCellProps(col, item, ctx);
|
|
2486
|
+
const Renderer = ctx.registry.resolve(col, false); // cards render read-only
|
|
2487
|
+
return jsx(Renderer, { ...cellProps });
|
|
2488
|
+
};
|
|
2489
|
+
const rawValue = (item, field) => {
|
|
2490
|
+
if (!field)
|
|
2491
|
+
return '';
|
|
2492
|
+
const v = item[field];
|
|
2493
|
+
return v == null ? '' : String(v);
|
|
2494
|
+
};
|
|
2495
|
+
const toggleSelect = (item, checked) => {
|
|
2496
|
+
const key = getKeyFn(item);
|
|
2497
|
+
setSelected((prev) => {
|
|
2498
|
+
const next = new Set(selectionMode === 'single' ? [] : prev);
|
|
2499
|
+
if (checked)
|
|
2500
|
+
next.add(key);
|
|
2501
|
+
else
|
|
2502
|
+
next.delete(key);
|
|
2503
|
+
onSelectionChanged?.(items.filter((it) => next.has(getKeyFn(it))));
|
|
2504
|
+
return next;
|
|
2505
|
+
});
|
|
2506
|
+
};
|
|
2507
|
+
const toggleExpand = useCallback((key, item) => {
|
|
2508
|
+
setExpanded((prev) => {
|
|
2509
|
+
const next = new Set(prev);
|
|
2510
|
+
if (next.has(key))
|
|
2511
|
+
next.delete(key);
|
|
2512
|
+
else {
|
|
2513
|
+
next.add(key);
|
|
2514
|
+
ensure(key, item);
|
|
2515
|
+
}
|
|
2516
|
+
return next;
|
|
2517
|
+
});
|
|
2518
|
+
}, [ensure]);
|
|
2519
|
+
return (jsxs(Stack, { styles: fillStackStyles(fill, height), children: [toolbar && jsx(GridToolbar, { config: toolbar }), jsx(FillRegion, { fill: fill, children: jsx("div", { style: {
|
|
2520
|
+
display: 'grid',
|
|
2521
|
+
gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))`,
|
|
2522
|
+
gap: 12,
|
|
2523
|
+
paddingTop: 4,
|
|
2524
|
+
}, children: items.map((item) => {
|
|
2525
|
+
const key = getKeyFn(item);
|
|
2526
|
+
const open = expanded.has(key);
|
|
2527
|
+
const entry = open ? get(key) : undefined;
|
|
2528
|
+
return (jsxs("div", { className: cardClass, style: cardHeight ? { height: cardHeight } : undefined, onClick: () => toggleExpand(key, item),
|
|
2529
|
+
// Right-click anywhere on the card opens its row menu. The card is the
|
|
2530
|
+
// row-equivalent target, so the title/body cells, chevron, and checkbox
|
|
2531
|
+
// below keep only their onClick stopPropagation — a right-click on them
|
|
2532
|
+
// intentionally bubbles here (preventDefault suppresses the browser/anchor
|
|
2533
|
+
// menu). Do NOT add onContextMenu guards there or the menu dies on cells.
|
|
2534
|
+
// The expanded child region DOES stop it (see below).
|
|
2535
|
+
onContextMenu: onItemContextMenu
|
|
2536
|
+
? (e) => {
|
|
2537
|
+
e.preventDefault();
|
|
2538
|
+
onItemContextMenu(item, undefined, e.nativeEvent);
|
|
2539
|
+
}
|
|
2540
|
+
: undefined, children: [jsxs(Stack, { horizontal: true, horizontalAlign: "space-between", verticalAlign: "start", children: [jsxs(Stack, { horizontal: true, verticalAlign: "center", tokens: { childrenGap: 4 }, children: [jsx(IconButton, { iconProps: { iconName: open ? 'ChevronDown' : 'ChevronRight' }, ariaLabel: open ? 'Collapse' : 'Expand', styles: { root: { height: 24, width: 24 } }, onClick: (e) => {
|
|
2541
|
+
e.stopPropagation();
|
|
2542
|
+
toggleExpand(key, item);
|
|
2543
|
+
} }), jsx(Text, { variant: "mediumPlus", styles: { root: { fontWeight: 600 } }, children: jsx("span", { onClick: (e) => e.stopPropagation(), children: renderField(byField.get(titleField ?? ''), item) ?? rawValue(item, titleField) }) })] }), selectionMode !== 'none' && (
|
|
2544
|
+
// Stop the click from bubbling to the card's expand toggle.
|
|
2545
|
+
jsx("span", { onClick: (e) => e.stopPropagation(), children: jsx(Checkbox, { checked: selected.has(key), onChange: (_, checked) => toggleSelect(item, checked), ariaLabel: "Select card" }) }))] }), imageField && rawValue(item, imageField) && (jsx("img", { className: cardImageClass, src: rawValue(item, imageField), alt: "" })), subtitleField && (jsx(Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: rawValue(item, subtitleField) })), bodyColumns.map((col) => (jsxs("div", { className: bodyRowClass, children: [jsx(Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: col.name }), jsx("span", { onClick: (e) => e.stopPropagation(), children: renderField(col, item) })] }, col.key))), open && (jsx("div", { style: { paddingTop: 8, marginTop: 4, borderTop: '1px solid #edebe9' }, onClick: (e) => e.stopPropagation(),
|
|
2546
|
+
// The child <DataGrid> renders INSIDE this card's DOM, so a right-click
|
|
2547
|
+
// on a child row would otherwise bubble to the card's onContextMenu and
|
|
2548
|
+
// fire the PARENT card's menu for the wrong record (the #93 / 2a8ff381
|
|
2549
|
+
// lesson; mirrors NestedInline's inline child-region guard). Children
|
|
2550
|
+
// have no own menu in v1 → the native browser menu is left intact here.
|
|
2551
|
+
onContextMenu: (e) => e.stopPropagation(), children: !entry || entry.loading ? (jsx(Spinner, { label: "Loading related records\u2026" })) : (jsx(DataGrid, { items: entry.rows, columns: nested.childColumns, registry: nested.childRegistry, compact: true, selectionMode: nested.childSelectionMode, onSelectionChanged: nested.onChildSelectionChanged
|
|
2552
|
+
? (sel) => nested.onChildSelectionChanged(item, sel)
|
|
2553
|
+
: undefined, editable: nested.childEditable, editTrigger: nested.childEditTrigger, onValueChange: nested.onChildValueChange
|
|
2554
|
+
? (rk, fn, v, ov) => nested.onChildValueChange(item, rk, fn, v, ov)
|
|
2555
|
+
: undefined })) }))] }, key));
|
|
2556
|
+
}) }) }), hasAggregate && (jsx(GridAggregateFooter, { items: aggregateItems ?? items, columns: columns, leadingSpacer: 0 })), pagination && jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange }), contextMenuElement] }));
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
/**
|
|
2560
|
+
* Resolve a nested trigger label, substituting the `{count}` token. When the
|
|
2561
|
+
* count isn't known yet (children not loaded), the token is dropped and extra
|
|
2562
|
+
* whitespace collapsed (e.g. "View {count} related" → "View related").
|
|
2563
|
+
*/
|
|
2564
|
+
function formatTriggerLabel(label, count) {
|
|
2565
|
+
const base = label ?? 'View {count} related';
|
|
2566
|
+
if (count === undefined) {
|
|
2567
|
+
return base.replace(/\{count\}\s*/g, '').replace(/\s+/g, ' ').trim();
|
|
2568
|
+
}
|
|
2569
|
+
return base.replace(/\{count\}/g, String(count));
|
|
2570
|
+
}
|
|
2571
|
+
/** Map a side-panel size token to a Fluent v8 `PanelType`. */
|
|
2572
|
+
function panelTypeFor(size) {
|
|
2573
|
+
return size === 'small' ? PanelType.smallFixedFar : size === 'large' ? PanelType.large : PanelType.medium;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
/**
|
|
2577
|
+
* Per-parent-row trigger cell for `side-panel` and `hover-callout` nested modes.
|
|
2578
|
+
* Children are resolved **lazily** — on click (panel) or first hover (callout) —
|
|
2579
|
+
* so a 50-row page doesn't fire 50 fetches up front.
|
|
2580
|
+
*/
|
|
2581
|
+
function NestedTriggerCell(props) {
|
|
2582
|
+
const { parent, nested } = props;
|
|
2583
|
+
const [entry, setEntry] = useState(null);
|
|
2584
|
+
const [open, setOpen] = useState(false);
|
|
2585
|
+
const [hovered, setHovered] = useState(false);
|
|
2586
|
+
const hostRef = useRef(null);
|
|
2587
|
+
const hoverTimer = useRef();
|
|
2588
|
+
const mounted = useRef(true);
|
|
2589
|
+
useEffect(() => {
|
|
2590
|
+
mounted.current = true;
|
|
2591
|
+
return () => {
|
|
2592
|
+
mounted.current = false;
|
|
2593
|
+
if (hoverTimer.current)
|
|
2594
|
+
clearTimeout(hoverTimer.current);
|
|
2595
|
+
};
|
|
2596
|
+
}, []);
|
|
2597
|
+
// Resolve children once, on demand.
|
|
2598
|
+
const load = useCallback(() => {
|
|
2599
|
+
setEntry((prev) => {
|
|
2600
|
+
if (prev)
|
|
2601
|
+
return prev;
|
|
2602
|
+
const result = nested.getChildren(parent);
|
|
2603
|
+
if (Array.isArray(result))
|
|
2604
|
+
return { loading: false, rows: result };
|
|
2605
|
+
Promise.resolve(result).then((rows) => mounted.current && setEntry({ loading: false, rows }));
|
|
2606
|
+
return { loading: true, rows: [] };
|
|
2607
|
+
});
|
|
2608
|
+
}, [parent, nested]);
|
|
2609
|
+
const count = entry && !entry.loading ? entry.rows.length : undefined;
|
|
2610
|
+
if (nested.mode === 'hover-callout') {
|
|
2611
|
+
const max = nested.calloutMaxRows ?? 5;
|
|
2612
|
+
const preview = entry ? entry.rows.slice(0, max) : [];
|
|
2613
|
+
return (jsxs("div", { ref: hostRef, style: { display: 'inline-block' }, onMouseEnter: () => {
|
|
2614
|
+
load();
|
|
2615
|
+
hoverTimer.current = setTimeout(() => mounted.current && setHovered(true), nested.hoverDelay ?? 300);
|
|
2616
|
+
}, onMouseLeave: () => {
|
|
2617
|
+
if (hoverTimer.current)
|
|
2618
|
+
clearTimeout(hoverTimer.current);
|
|
2619
|
+
setHovered(false);
|
|
2620
|
+
}, children: [jsx(Text, { variant: "small", styles: { root: { background: '#edebe9', borderRadius: 10, padding: '2px 10px', cursor: 'default' } }, children: entry?.loading ? '…' : count !== undefined ? `${count} related` : 'related' }), hovered && entry && !entry.loading && entry.rows.length > 0 && (jsx(Callout, { target: hostRef.current, onDismiss: () => setHovered(false), directionalHint: DirectionalHint.bottomLeftEdge, isBeakVisible: false, gapSpace: 4, children: jsxs("div", { style: { padding: 8, minWidth: 360, maxWidth: 560 }, children: [jsx(ReadOnlyGrid, { items: preview, columns: nested.childColumns, registry: nested.childRegistry, compact: true }), entry.rows.length > max && (jsxs(Text, { variant: "small", styles: { root: { color: '#605e5c', paddingTop: 4 } }, children: ["+", entry.rows.length - max, " more"] }))] }) }))] }));
|
|
2621
|
+
}
|
|
2622
|
+
// side-panel (default for triggered modes) — fetch on open.
|
|
2623
|
+
const label = formatTriggerLabel(nested.triggerLabel, count);
|
|
2624
|
+
return (jsxs(Fragment, { children: [jsx(ActionButton, { iconProps: { iconName: nested.triggerIcon ?? 'OpenPaneMirrored' }, onClick: () => {
|
|
2625
|
+
load();
|
|
2626
|
+
setOpen(true);
|
|
2627
|
+
}, styles: { root: { height: 28 } }, children: label }), jsx(Panel, { isOpen: open, onDismiss: () => setOpen(false), type: panelTypeFor(nested.panelSize), headerText: label, closeButtonAriaLabel: "Close", children: !entry || entry.loading ? (jsx(Spinner, { label: "Loading related records\u2026" })) : (jsx(DataGrid, { items: entry.rows, columns: nested.childColumns, registry: nested.childRegistry, compact: true, selectionMode: nested.childSelectionMode, onSelectionChanged: nested.onChildSelectionChanged ? (sel) => nested.onChildSelectionChanged(parent, sel) : undefined, editable: nested.childEditable, editTrigger: nested.childEditTrigger, onValueChange: nested.onChildValueChange
|
|
2628
|
+
? (rk, fn, v, ov) => nested.onChildValueChange(parent, rk, fn, v, ov)
|
|
2629
|
+
: undefined })) })] }));
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
/**
|
|
2633
|
+
* Nested / expandable grid host. Dispatch order:
|
|
2634
|
+
* 1. `parentLayout === 'cards'` — each parent is a card that expands in place
|
|
2635
|
+
* (`<NestedCardParent>`); `mode` is ignored.
|
|
2636
|
+
* Otherwise, parents are rows and child records surface per `nested.mode`:
|
|
2637
|
+
* - `inline` — chevron expands the child grid under the row
|
|
2638
|
+
* - `side-panel` — a trigger opens a Fluent Panel with the child grid
|
|
2639
|
+
* - `hover-callout` — a count badge shows a compact callout of top-N rows
|
|
2640
|
+
* - `detail-pane` — falls back to `side-panel` here (use `<FocusedViewGrid>`
|
|
2641
|
+
* for true master-detail with a detail-pane child)
|
|
2642
|
+
*
|
|
2643
|
+
* grid-kit never fetches — `nested.getChildren(parent)` (sync or async) owns it.
|
|
2644
|
+
*/
|
|
2645
|
+
function NestedGrid(props) {
|
|
2646
|
+
const { columns, nested } = props;
|
|
2647
|
+
const edit = useEditState();
|
|
2648
|
+
const { getKeyFn, ctx } = useGridContext(props, edit);
|
|
2649
|
+
// Append a trigger column for the non-inline modes (its cell manages its own
|
|
2650
|
+
// panel/callout). Built before any early return to keep hook order stable.
|
|
2651
|
+
const triggeredColumns = useMemo(() => {
|
|
2652
|
+
const trigger = {
|
|
2653
|
+
key: '__nested',
|
|
2654
|
+
fieldName: '__nested',
|
|
2655
|
+
name: 'Related',
|
|
2656
|
+
rendererType: 'text',
|
|
2657
|
+
minWidth: 160,
|
|
2658
|
+
onRender: (item) => jsx(NestedTriggerCell, { parent: item, nested: nested }),
|
|
2659
|
+
};
|
|
2660
|
+
return [...columns, trigger];
|
|
2661
|
+
}, [columns, nested]);
|
|
2662
|
+
// Card parents win over `mode`: each parent renders as a card that expands in place.
|
|
2663
|
+
if (nested.parentLayout === 'cards') {
|
|
2664
|
+
return jsx(NestedCardParent, { parentProps: props, ctx: ctx, getKeyFn: getKeyFn });
|
|
2665
|
+
}
|
|
2666
|
+
if (nested.mode === 'inline') {
|
|
2667
|
+
return jsx(NestedInline, { parentProps: props, ctx: ctx, getKeyFn: getKeyFn });
|
|
2668
|
+
}
|
|
2669
|
+
// side-panel, hover-callout (and detail-pane fallback) → DataGrid + trigger column
|
|
2670
|
+
return jsx(DataGrid, { ...props, columns: triggeredColumns });
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
const railClass = mergeStyles({ width: 320, minWidth: 260, borderRight: '1px solid #edebe9', overflowY: 'auto' });
|
|
2674
|
+
const railItemBase = {
|
|
2675
|
+
padding: '10px 12px',
|
|
2676
|
+
borderBottom: '1px solid #f3f2f1',
|
|
2677
|
+
cursor: 'pointer',
|
|
2678
|
+
display: 'flex',
|
|
2679
|
+
gap: 10,
|
|
2680
|
+
alignItems: 'flex-start',
|
|
2681
|
+
};
|
|
2682
|
+
const detailRowClass = mergeStyles({
|
|
2683
|
+
display: 'flex',
|
|
2684
|
+
justifyContent: 'space-between',
|
|
2685
|
+
gap: 12,
|
|
2686
|
+
padding: '6px 0',
|
|
2687
|
+
borderBottom: '1px solid #f3f2f1',
|
|
2688
|
+
fontSize: 13,
|
|
2689
|
+
});
|
|
2690
|
+
/**
|
|
2691
|
+
* Focused-view / master-detail host. A left rail of summary items (each rendered
|
|
2692
|
+
* from `focusedView.rows`) drives a right detail pane (the active record's
|
|
2693
|
+
* `detailFields`, each value through the registry). When a `nested` config with
|
|
2694
|
+
* `mode: 'detail-pane'` is supplied, the child grid renders inside the pane.
|
|
2695
|
+
*/
|
|
2696
|
+
function FocusedViewGrid(props) {
|
|
2697
|
+
const { items, columns, focusedView, nested, onActiveItemChanged, fill, height, rowCommands } = props;
|
|
2698
|
+
// The detail pane renders read-only (registry.resolve(col, false)); useEditState
|
|
2699
|
+
// is threaded only because useGridContext bundles it — no inline editing here.
|
|
2700
|
+
const edit = useEditState();
|
|
2701
|
+
const { getKeyFn, ctx } = useGridContext(props, edit);
|
|
2702
|
+
// Per-row right-click menu on the left rail (parity with DataGrid/NestedInline). The
|
|
2703
|
+
// rail items are raw divs, so we invoke the hook fn from a native onContextMenu and
|
|
2704
|
+
// suppress the browser menu with e.preventDefault() (the hook's `return false` only
|
|
2705
|
+
// matters to Fluent's DetailsList contract). No child stopPropagation is needed: the
|
|
2706
|
+
// detail pane is a SIBLING of the rail (not nested inside a rail item), unlike the
|
|
2707
|
+
// inline child grids in NestedInline / NestedCardParent.
|
|
2708
|
+
const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
|
|
2709
|
+
const [activeKey, setActiveKey] = useState(() => (items[0] ? getKeyFn(items[0]) : undefined));
|
|
2710
|
+
const [search, setSearch] = useState('');
|
|
2711
|
+
const byField = useMemo(() => {
|
|
2712
|
+
const m = new Map();
|
|
2713
|
+
columns.forEach((c) => m.set(c.fieldName, c));
|
|
2714
|
+
return m;
|
|
2715
|
+
}, [columns]);
|
|
2716
|
+
const filtered = useMemo(() => {
|
|
2717
|
+
const q = search.trim().toLowerCase();
|
|
2718
|
+
if (!q)
|
|
2719
|
+
return items;
|
|
2720
|
+
const fields = focusedView.rows.flatMap((r) => [r.primaryField.fieldName, r.secondaryField?.fieldName].filter(Boolean));
|
|
2721
|
+
return items.filter((it) => fields.some((f) => String(getFieldValue(it, f) ?? '').toLowerCase().includes(q)));
|
|
2722
|
+
}, [items, search, focusedView.rows]);
|
|
2723
|
+
const active = useMemo(() => items.find((it) => getKeyFn(it) === activeKey), [items, activeKey, getKeyFn]);
|
|
2724
|
+
const detailColumns = useMemo(() => {
|
|
2725
|
+
if (!focusedView.detailFields)
|
|
2726
|
+
return columns;
|
|
2727
|
+
return focusedView.detailFields.map((f) => byField.get(f)).filter(Boolean);
|
|
2728
|
+
}, [focusedView.detailFields, byField, columns]);
|
|
2729
|
+
const fieldText = (item, field) => {
|
|
2730
|
+
if (!field)
|
|
2731
|
+
return '';
|
|
2732
|
+
const v = getFieldValue(item, field.fieldName);
|
|
2733
|
+
return v == null ? '' : String(v);
|
|
2734
|
+
};
|
|
2735
|
+
const select = (item) => {
|
|
2736
|
+
setActiveKey(getKeyFn(item));
|
|
2737
|
+
onActiveItemChanged?.(item);
|
|
2738
|
+
};
|
|
2739
|
+
const renderDetailValue = (col, item) => {
|
|
2740
|
+
const cellProps = buildCellProps(col, item, ctx);
|
|
2741
|
+
const Renderer = ctx.registry.resolve(col, false);
|
|
2742
|
+
return jsx(Renderer, { ...cellProps });
|
|
2743
|
+
};
|
|
2744
|
+
return (jsxs(Fragment, { children: [jsxs(Stack, { horizontal: true, styles: { root: { border: '1px solid #edebe9', borderRadius: 4, height: height ?? (fill ? '100%' : 460) } }, children: [jsxs("div", { className: railClass, children: [jsx("div", { style: { padding: 8, borderBottom: '1px solid #edebe9' }, children: jsx(SearchBox, { placeholder: "Search", onChange: (_, v) => setSearch(v ?? ''), underlined: true }) }), filtered.map((item) => {
|
|
2745
|
+
const key = getKeyFn(item);
|
|
2746
|
+
const isActive = key === activeKey;
|
|
2747
|
+
return (jsxs("div", { style: { ...railItemBase, background: isActive ? '#e1efff' : undefined, borderLeft: isActive ? '3px solid #0078d4' : '3px solid transparent' }, onClick: () => select(item), onContextMenu: onItemContextMenu
|
|
2748
|
+
? (e) => {
|
|
2749
|
+
e.preventDefault();
|
|
2750
|
+
onItemContextMenu(item, undefined, e.nativeEvent);
|
|
2751
|
+
}
|
|
2752
|
+
: undefined, children: [focusedView.rows[0]?.iconName && jsx(Icon, { iconName: focusedView.rows[0].iconName, styles: { root: { fontSize: 18, color: '#0078d4' } } }), jsx(Stack, { tokens: { childrenGap: 2 }, children: focusedView.rows.map((row) => (jsxs("div", { children: [jsx(Text, { variant: "medium", styles: { root: { fontWeight: 600 } }, children: fieldText(item, row.primaryField) }), row.secondaryField && (jsx(Text, { variant: "small", styles: { root: { color: '#605e5c', paddingLeft: 6 } }, children: fieldText(item, row.secondaryField) }))] }, row.id))) })] }, key));
|
|
2753
|
+
})] }), jsx("div", { style: { flex: 1, padding: 16, overflowY: 'auto' }, children: !active ? (jsx(Text, { variant: "medium", styles: { root: { color: '#605e5c' } }, children: "Select a record to see its details." })) : focusedView.childOnly ? (
|
|
2754
|
+
// childOnly: the pane IS the related-records grid — no title heading, no
|
|
2755
|
+
// detail-field list, no "Related" subheading. Pure master/detail.
|
|
2756
|
+
nested ? (jsx(DetailPaneChildren, { parent: active, nested: nested })) : (jsx(Text, { variant: "medium", styles: { root: { color: '#605e5c' } }, children: "No related records configured." }))) : (jsxs(Stack, { tokens: { childrenGap: 4 }, children: [jsx(Text, { variant: "large", styles: { root: { fontWeight: 600, marginBottom: 8 } }, children: fieldText(active, focusedView.rows[0]?.primaryField) }), detailColumns.map((col) => (jsxs("div", { className: detailRowClass, children: [jsx(Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: col.name }), jsx("span", { children: renderDetailValue(col, active) })] }, col.key))), nested && nested.mode === 'detail-pane' && (jsxs("div", { style: { paddingTop: 16 }, children: [jsx(Text, { variant: "mediumPlus", styles: { root: { fontWeight: 600 } }, children: "Related" }), jsx(DetailPaneChildren, { parent: active, nested: nested })] }))] })) })] }), contextMenuElement] }));
|
|
2757
|
+
}
|
|
2758
|
+
/** Resolves + renders the detail-pane child grid for the active record. */
|
|
2759
|
+
function DetailPaneChildren(props) {
|
|
2760
|
+
const { parent, nested } = props;
|
|
2761
|
+
const [rows, setRows] = useState(null);
|
|
2762
|
+
React.useEffect(() => {
|
|
2763
|
+
let alive = true;
|
|
2764
|
+
const result = nested.getChildren(parent);
|
|
2765
|
+
if (Array.isArray(result))
|
|
2766
|
+
setRows(result);
|
|
2767
|
+
else {
|
|
2768
|
+
setRows(null);
|
|
2769
|
+
Promise.resolve(result).then((r) => alive && setRows(r));
|
|
2770
|
+
}
|
|
2771
|
+
return () => {
|
|
2772
|
+
alive = false;
|
|
2773
|
+
};
|
|
2774
|
+
}, [parent, nested]);
|
|
2775
|
+
if (rows === null)
|
|
2776
|
+
return jsx(Text, { variant: "small", children: "Loading\u2026" });
|
|
2777
|
+
return (jsx(DataGrid, { items: rows, columns: nested.childColumns, registry: nested.childRegistry, compact: true, selectionMode: nested.childSelectionMode, onSelectionChanged: nested.onChildSelectionChanged ? (sel) => nested.onChildSelectionChanged(parent, sel) : undefined, editable: nested.childEditable, editTrigger: nested.childEditTrigger, onValueChange: nested.onChildValueChange
|
|
2778
|
+
? (rk, fn, v, ov) => nested.onChildValueChange(parent, rk, fn, v, ov)
|
|
2779
|
+
: undefined }));
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
export { CardGrid, DataGrid, DataGrid as DetailsGrid, DroppableCell, FileDropCell, FocusedViewGrid, GridColumnChooser, GridExportDialog, GridFilterBuilder, GridToolbar, GroupedGrid, NestedGrid, ReadOnlyGrid, applyFilters, buildCellProps, buildGroups, computeAggregate, computeAggregates, computeFormattedValue, computeGroupAggregates, createCellRegistry, defaultGetKey, evalCondition, exportGrid, fieldTypeForRenderer, fromGridCustomizerColumn, fromGridCustomizerDefinition, getFieldValue, getOperatorsForFieldType, getSuppliedFormattedValue, isConditionComplete, isFileAccepted, makeEditWrapper, makeReadWrapper, mapFormRuntimeRendererType, navigateToTarget, operatorIsMultiValue, operatorRequiresValue, orderVisibleColumns, rendererToFieldType, resolveCellColor, resolveExportColumns, resolveGridFromDefinition, resolveThreshold, toBool, toDate, toDetailsListColumns, toDisplayString, toExportColumns, toGridCustomizerOverrides, toNumber, toReusableColumn, useChildren, useEditState, useFileDrop, useGridContext };
|
|
2783
|
+
//# sourceMappingURL=index.esm.js.map
|