@mohsinonxrm/pptb-fetchxml-studio 1.0.0 → 1.0.2
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/LICENSE +661 -0
- package/README.md +1 -1
- package/dist/assets/index-DD9fzkbz.js +232 -0
- package/dist/index.html +70 -0
- package/index.html +0 -1
- package/npm-shrinkwrap.json +6329 -0
- package/package.json +95 -51
- package/CHANGELOG.md +0 -120
- package/eslint.config.js +0 -23
- package/public/vite.svg +0 -1
- package/src/app/AppShell.tsx +0 -1733
- package/src/assets/20px-Microsoft_Office_Excel_(2025/342/200/223present).svg.png +0 -0
- package/src/assets/react.svg +0 -1
- package/src/features/fetchxml/api/dataverseMetadata.ts +0 -192
- package/src/features/fetchxml/api/excelExport.ts +0 -318
- package/src/features/fetchxml/api/formattedValues.ts +0 -107
- package/src/features/fetchxml/api/pptbClient.ts +0 -2635
- package/src/features/fetchxml/model/displaySettings.ts +0 -18
- package/src/features/fetchxml/model/fetchxml.ts +0 -346
- package/src/features/fetchxml/model/fetchxmlIntellisense.ts +0 -583
- package/src/features/fetchxml/model/fetchxmlParser.ts +0 -708
- package/src/features/fetchxml/model/layoutxml.ts +0 -372
- package/src/features/fetchxml/model/nodes.ts +0 -255
- package/src/features/fetchxml/model/operators.ts +0 -624
- package/src/features/fetchxml/model/treeUtils.ts +0 -174
- package/src/features/fetchxml/state/builderStore.tsx +0 -1160
- package/src/features/fetchxml/state/cache.ts +0 -393
- package/src/features/fetchxml/ui/Dialogs/BulkDeleteDialog.tsx +0 -352
- package/src/features/fetchxml/ui/Dialogs/DeleteConfirmDialog.tsx +0 -396
- package/src/features/fetchxml/ui/Dialogs/SaveViewDialog.tsx +0 -612
- package/src/features/fetchxml/ui/Dialogs/SolutionPicker.tsx +0 -114
- package/src/features/fetchxml/ui/Dialogs/WorkflowPickerDialog.tsx +0 -437
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/AttributeEditor.tsx +0 -214
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ConditionEditor.tsx +0 -533
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FetchOptionsEditor.tsx +0 -296
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FilterEditor.tsx +0 -106
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/LinkEntityEditor.tsx +0 -433
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/OrderEditor.tsx +0 -161
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ValueOfPicker.tsx +0 -194
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel.tsx +0 -225
- package/src/features/fetchxml/ui/LeftPane/TreeView.tsx +0 -604
- package/src/features/fetchxml/ui/RightPane/AddColumnsPanel.tsx +0 -800
- package/src/features/fetchxml/ui/RightPane/ColumnConfigDialog.tsx +0 -170
- package/src/features/fetchxml/ui/RightPane/DataGridCellRenderers.tsx +0 -356
- package/src/features/fetchxml/ui/RightPane/EditColumnsPanel.tsx +0 -372
- package/src/features/fetchxml/ui/RightPane/FetchXmlEditor.tsx +0 -399
- package/src/features/fetchxml/ui/RightPane/FormattedValueUtils.ts +0 -189
- package/src/features/fetchxml/ui/RightPane/LayoutXmlViewer.tsx +0 -171
- package/src/features/fetchxml/ui/RightPane/PreviewTabs.tsx +0 -471
- package/src/features/fetchxml/ui/RightPane/ResultsCommandBar.tsx +0 -430
- package/src/features/fetchxml/ui/RightPane/ResultsGrid.tsx +0 -983
- package/src/features/fetchxml/ui/RightPane/XmlTextArea.tsx +0 -65
- package/src/features/fetchxml/ui/Settings/SettingsDrawer.tsx +0 -175
- package/src/features/fetchxml/ui/Toolbar/EntitySelector.tsx +0 -788
- package/src/features/fetchxml/ui/Toolbar/LoadViewPicker.tsx +0 -274
- package/src/features/fetchxml/ui/Toolbar/SaveViewButton.tsx +0 -174
- package/src/main.tsx +0 -11
- package/src/shared/components/AttributePicker.tsx +0 -138
- package/src/shared/components/BetweenInputPicker.tsx +0 -120
- package/src/shared/components/BooleanValuePicker.tsx +0 -165
- package/src/shared/components/DateTimeInputPicker.tsx +0 -170
- package/src/shared/components/DynamicValueList.tsx +0 -128
- package/src/shared/components/ExcelIcon.tsx +0 -30
- package/src/shared/components/MultiValuePicker.tsx +0 -178
- package/src/shared/components/NumericInputPicker.tsx +0 -156
- package/src/shared/components/OptionSetValuePicker.tsx +0 -150
- package/src/shared/components/RelationshipPicker.tsx +0 -268
- package/src/shared/contexts/ThemeContext.tsx +0 -19
- package/src/shared/hooks/useAccessMode.ts +0 -77
- package/src/shared/hooks/useLazyMetadata.ts +0 -132
- package/src/shared/hooks/usePptbContext.ts +0 -170
- package/src/shared/hooks/usePublisherFilter.ts +0 -214
- package/src/shared/hooks/useSolutionFilter.ts +0 -162
- package/src/shared/utils/debug.ts +0 -87
- package/tsconfig.app.json +0 -28
- package/tsconfig.json +0 -7
- package/tsconfig.node.json +0 -26
- package/vite.config.ts +0 -7
|
@@ -1,983 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DataGrid for displaying FetchXML query results with virtualization
|
|
3
|
-
* Uses rich cell renderers based on attribute metadata for Power Apps-like experience
|
|
4
|
-
* Displays formatted values from OData annotations when available
|
|
5
|
-
* Uses @fluentui-contrib/react-data-grid-react-window for 1D virtualization with multi-select and resizable columns
|
|
6
|
-
* Sort state is derived from FetchXML orders - clicking headers triggers onSortChange callback
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { useMemo, useCallback, useEffect, useState, useRef } from "react";
|
|
10
|
-
import {
|
|
11
|
-
createTableColumn,
|
|
12
|
-
makeStyles,
|
|
13
|
-
tokens,
|
|
14
|
-
TableCellLayout,
|
|
15
|
-
Skeleton,
|
|
16
|
-
SkeletonItem,
|
|
17
|
-
} from "@fluentui/react-components";
|
|
18
|
-
import {
|
|
19
|
-
DataGrid,
|
|
20
|
-
DataGridHeader,
|
|
21
|
-
DataGridHeaderCell,
|
|
22
|
-
DataGridBody,
|
|
23
|
-
DataGridRow,
|
|
24
|
-
DataGridCell,
|
|
25
|
-
} from "@fluentui-contrib/react-data-grid-react-window";
|
|
26
|
-
import type { RowRenderer } from "@fluentui-contrib/react-data-grid-react-window";
|
|
27
|
-
import { useScrollbarWidth, useFluent } from "@fluentui/react-components";
|
|
28
|
-
import type { SortDirection } from "@fluentui/react-components";
|
|
29
|
-
import { ArrowSortUp16Regular, ArrowSortDown16Regular } from "@fluentui/react-icons";
|
|
30
|
-
import type { AttributeMetadata } from "../../api/pptbClient";
|
|
31
|
-
import { getCellRenderer } from "./DataGridCellRenderers";
|
|
32
|
-
import { getFormattedValue, filterDisplayableColumns } from "./FormattedValueUtils";
|
|
33
|
-
import type { FetchNode, AttributeNode, OrderNode } from "../../model/nodes";
|
|
34
|
-
import type { LayoutXmlConfig } from "../../model/layoutxml";
|
|
35
|
-
import type { DisplaySettings } from "../../model/displaySettings";
|
|
36
|
-
|
|
37
|
-
/** Sort change event data passed to parent */
|
|
38
|
-
export interface SortChangeData {
|
|
39
|
-
/** Column being sorted (may include entityname prefix for link-entity attributes) */
|
|
40
|
-
columnId: string;
|
|
41
|
-
/** New sort direction */
|
|
42
|
-
direction: SortDirection;
|
|
43
|
-
/** Whether Shift key was held (multi-column sort) */
|
|
44
|
-
isMultiSort: boolean;
|
|
45
|
-
/** The entityname for link-entity attributes, undefined for root entity */
|
|
46
|
-
entityName?: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const ROW_HEIGHT = 42;
|
|
50
|
-
|
|
51
|
-
const useStyles = makeStyles({
|
|
52
|
-
container: {
|
|
53
|
-
display: "flex",
|
|
54
|
-
flexDirection: "column",
|
|
55
|
-
height: "100%",
|
|
56
|
-
backgroundColor: tokens.colorNeutralBackground1,
|
|
57
|
-
position: "relative",
|
|
58
|
-
},
|
|
59
|
-
gridWrapper: {
|
|
60
|
-
flex: 1,
|
|
61
|
-
display: "flex",
|
|
62
|
-
flexDirection: "column",
|
|
63
|
-
overflow: "hidden",
|
|
64
|
-
backgroundColor: tokens.colorNeutralBackground1,
|
|
65
|
-
position: "relative",
|
|
66
|
-
minHeight: 0, // Important for flex child with overflow
|
|
67
|
-
},
|
|
68
|
-
gridContent: {
|
|
69
|
-
flex: 1,
|
|
70
|
-
display: "flex",
|
|
71
|
-
flexDirection: "column",
|
|
72
|
-
overflow: "hidden", // Hidden to prevent container scroll - DataGrid handles its own scrolling
|
|
73
|
-
width: "100%",
|
|
74
|
-
minHeight: 0, // Important for flex child with overflow
|
|
75
|
-
},
|
|
76
|
-
infoBar: {
|
|
77
|
-
display: "flex",
|
|
78
|
-
alignItems: "center",
|
|
79
|
-
justifyContent: "space-between",
|
|
80
|
-
padding: "8px 16px",
|
|
81
|
-
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
|
82
|
-
backgroundColor: tokens.colorNeutralBackground1,
|
|
83
|
-
fontSize: tokens.fontSizeBase200,
|
|
84
|
-
color: tokens.colorNeutralForeground2,
|
|
85
|
-
minHeight: "32px",
|
|
86
|
-
zIndex: 1,
|
|
87
|
-
},
|
|
88
|
-
selectionWarning: {
|
|
89
|
-
color: tokens.colorPaletteYellowForeground1,
|
|
90
|
-
marginLeft: tokens.spacingHorizontalM,
|
|
91
|
-
},
|
|
92
|
-
emptyState: {
|
|
93
|
-
display: "flex",
|
|
94
|
-
alignItems: "center",
|
|
95
|
-
justifyContent: "center",
|
|
96
|
-
height: "100%",
|
|
97
|
-
color: tokens.colorNeutralForeground3,
|
|
98
|
-
fontSize: tokens.fontSizeBase300,
|
|
99
|
-
},
|
|
100
|
-
cellContent: {
|
|
101
|
-
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
|
102
|
-
overflow: "hidden",
|
|
103
|
-
textOverflow: "ellipsis",
|
|
104
|
-
whiteSpace: "nowrap",
|
|
105
|
-
},
|
|
106
|
-
row: {
|
|
107
|
-
boxSizing: "border-box",
|
|
108
|
-
height: "44px",
|
|
109
|
-
},
|
|
110
|
-
body: {
|
|
111
|
-
scrollbarGutter: "stable",
|
|
112
|
-
},
|
|
113
|
-
headerCellContent: {
|
|
114
|
-
display: "flex",
|
|
115
|
-
alignItems: "center",
|
|
116
|
-
gap: tokens.spacingHorizontalXS,
|
|
117
|
-
},
|
|
118
|
-
sortIndicator: {
|
|
119
|
-
display: "flex",
|
|
120
|
-
alignItems: "center",
|
|
121
|
-
marginLeft: "auto",
|
|
122
|
-
},
|
|
123
|
-
// Hide Fluent's default sort indicator - we render our own in renderHeaderCell
|
|
124
|
-
headerCell: {
|
|
125
|
-
"& > button > span:last-child": {
|
|
126
|
-
display: "none",
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
export interface QueryResult {
|
|
132
|
-
columns: string[];
|
|
133
|
-
rows: Record<string, unknown>[];
|
|
134
|
-
totalRecordCount?: number;
|
|
135
|
-
moreRecords?: boolean;
|
|
136
|
-
pagingCookie?: string;
|
|
137
|
-
entityLogicalName?: string; // NEW: for loading attribute metadata
|
|
138
|
-
executionTimeMs?: number; // NEW: for execution timing display
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
interface ResultsGridProps {
|
|
142
|
-
result: QueryResult | null;
|
|
143
|
-
isLoading?: boolean;
|
|
144
|
-
/** Whether more pages are currently being loaded (for infinite scroll / Retrieve All) */
|
|
145
|
-
isLoadingMore?: boolean;
|
|
146
|
-
/** Multi-entity attribute metadata: Map<entityLogicalName, Map<attributeLogicalName, AttributeMetadata>> */
|
|
147
|
-
attributeMetadata?: Map<string, Map<string, AttributeMetadata>>;
|
|
148
|
-
fetchQuery?: FetchNode | null; // For extracting aliases and order state
|
|
149
|
-
onSelectedCountChange?: (count: number) => void;
|
|
150
|
-
/** Callback when selection changes, provides the selected record IDs (GUIDs) */
|
|
151
|
-
onSelectionChange?: (recordIds: string[]) => void;
|
|
152
|
-
/** Column layout configuration for order and widths */
|
|
153
|
-
columnConfig?: LayoutXmlConfig | null;
|
|
154
|
-
/** Callback when a column is resized */
|
|
155
|
-
onColumnResize?: (columnName: string, newWidth: number) => void;
|
|
156
|
-
/** Callback when user clicks a column header to sort */
|
|
157
|
-
onSortChange?: (data: SortChangeData) => void;
|
|
158
|
-
/** Callback when user scrolls near bottom and more records are available (infinite scroll) */
|
|
159
|
-
onLoadMore?: () => void;
|
|
160
|
-
/** Display settings (logical names, value format) */
|
|
161
|
-
displaySettings?: DisplaySettings;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function ResultsGrid({
|
|
165
|
-
result,
|
|
166
|
-
isLoading,
|
|
167
|
-
isLoadingMore,
|
|
168
|
-
attributeMetadata,
|
|
169
|
-
fetchQuery,
|
|
170
|
-
onSelectedCountChange,
|
|
171
|
-
onSelectionChange,
|
|
172
|
-
columnConfig,
|
|
173
|
-
onColumnResize,
|
|
174
|
-
onSortChange,
|
|
175
|
-
onLoadMore,
|
|
176
|
-
displaySettings,
|
|
177
|
-
}: ResultsGridProps) {
|
|
178
|
-
const styles = useStyles();
|
|
179
|
-
const { targetDocument } = useFluent();
|
|
180
|
-
const scrollbarWidth = useScrollbarWidth({ targetDocument });
|
|
181
|
-
const [selectedItems, setSelectedItems] = useState<Set<string | number>>(new Set());
|
|
182
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
183
|
-
const headerRef = useRef<HTMLDivElement>(null);
|
|
184
|
-
const [gridDimensions, setGridDimensions] = useState({ width: 0, height: 0 });
|
|
185
|
-
const [headerHeight, setHeaderHeight] = useState(0);
|
|
186
|
-
|
|
187
|
-
// Measure container dimensions for virtualization
|
|
188
|
-
useEffect(() => {
|
|
189
|
-
const container = containerRef.current;
|
|
190
|
-
if (!container) {
|
|
191
|
-
console.log("[ResultsGrid] ResizeObserver: container ref is null");
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
console.log("[ResultsGrid] ResizeObserver: setting up observer on container", container);
|
|
196
|
-
|
|
197
|
-
const observer = new ResizeObserver((entries) => {
|
|
198
|
-
for (const entry of entries) {
|
|
199
|
-
const { width, height } = entry.contentRect;
|
|
200
|
-
console.log("[ResultsGrid] ResizeObserver: dimensions updated", { width, height });
|
|
201
|
-
setGridDimensions({ width, height });
|
|
202
|
-
}
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
observer.observe(container);
|
|
206
|
-
return () => observer.disconnect();
|
|
207
|
-
}, [result]); // Re-run when result changes to ensure container is mounted
|
|
208
|
-
|
|
209
|
-
// Measure header height for proper body height calculation
|
|
210
|
-
useEffect(() => {
|
|
211
|
-
const header = headerRef.current;
|
|
212
|
-
if (!header) return;
|
|
213
|
-
|
|
214
|
-
const observer = new ResizeObserver(() => {
|
|
215
|
-
const height = header.offsetHeight;
|
|
216
|
-
console.log("[ResultsGrid] Header height:", height);
|
|
217
|
-
setHeaderHeight(height);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
observer.observe(header);
|
|
221
|
-
return () => observer.disconnect();
|
|
222
|
-
}, [result]);
|
|
223
|
-
|
|
224
|
-
// Clear selection when results change
|
|
225
|
-
useEffect(() => {
|
|
226
|
-
setSelectedItems(new Set());
|
|
227
|
-
}, [result]);
|
|
228
|
-
|
|
229
|
-
// Derive sort state from FetchXML orders (entity + link-entity orders)
|
|
230
|
-
// This builds a map from column name to sort direction
|
|
231
|
-
const sortStateMap = useMemo(() => {
|
|
232
|
-
const map = new Map<string, { direction: SortDirection; entityName?: string }>();
|
|
233
|
-
if (!fetchQuery?.entity) return map;
|
|
234
|
-
|
|
235
|
-
// Build a reverse map from attribute name to alias for root entity
|
|
236
|
-
const attrToAliasMap = new Map<string, string>();
|
|
237
|
-
fetchQuery.entity.attributes?.forEach((attr) => {
|
|
238
|
-
if (attr.alias) {
|
|
239
|
-
attrToAliasMap.set(attr.name, attr.alias);
|
|
240
|
-
}
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// Process root entity orders
|
|
244
|
-
// Orders with entityname refer to link-entity attributes
|
|
245
|
-
fetchQuery.entity.orders?.forEach((order: OrderNode) => {
|
|
246
|
-
const direction: SortDirection = order.descending ? "descending" : "ascending";
|
|
247
|
-
if (order.entityname) {
|
|
248
|
-
// This order references a link-entity attribute
|
|
249
|
-
// The column in results might be: alias.attributeName or _attributeName_value for lookups
|
|
250
|
-
map.set(`${order.entityname}.${order.attribute}`, {
|
|
251
|
-
direction,
|
|
252
|
-
entityName: order.entityname,
|
|
253
|
-
});
|
|
254
|
-
// Also map just the attribute name in case it appears that way
|
|
255
|
-
map.set(order.attribute, { direction, entityName: order.entityname });
|
|
256
|
-
} else {
|
|
257
|
-
// Root entity attribute
|
|
258
|
-
map.set(order.attribute, { direction });
|
|
259
|
-
// Also check for lookup field naming convention
|
|
260
|
-
map.set(`_${order.attribute}_value`, { direction });
|
|
261
|
-
// Also map by alias if this attribute has one
|
|
262
|
-
const alias = attrToAliasMap.get(order.attribute);
|
|
263
|
-
if (alias) {
|
|
264
|
-
map.set(alias, { direction });
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// Process link-entity orders (orders defined inside link-entity)
|
|
270
|
-
const processLinkOrders = (links: typeof fetchQuery.entity.links) => {
|
|
271
|
-
links?.forEach((link) => {
|
|
272
|
-
const alias = link.alias || link.name;
|
|
273
|
-
link.orders?.forEach((order: OrderNode) => {
|
|
274
|
-
const direction: SortDirection = order.descending ? "descending" : "ascending";
|
|
275
|
-
// Link-entity attribute columns appear as alias.attributeName or entityname.attributeName
|
|
276
|
-
map.set(`${alias}.${order.attribute}`, { direction, entityName: alias });
|
|
277
|
-
map.set(`${link.name}.${order.attribute}`, { direction, entityName: alias });
|
|
278
|
-
});
|
|
279
|
-
// Recurse into nested link-entities
|
|
280
|
-
if (link.links) {
|
|
281
|
-
processLinkOrders(link.links);
|
|
282
|
-
}
|
|
283
|
-
});
|
|
284
|
-
};
|
|
285
|
-
processLinkOrders(fetchQuery.entity.links);
|
|
286
|
-
|
|
287
|
-
return map;
|
|
288
|
-
}, [fetchQuery]);
|
|
289
|
-
|
|
290
|
-
// Get primary ID column name (typically {entity}id) from the FetchXML root entity
|
|
291
|
-
const primaryIdColumn = useMemo(() => {
|
|
292
|
-
// Use the root entity from FetchXML if available, otherwise fall back to result entity
|
|
293
|
-
const entityName = fetchQuery?.entity?.name || result?.entityLogicalName;
|
|
294
|
-
if (!entityName) return null;
|
|
295
|
-
return `${entityName}id`;
|
|
296
|
-
}, [fetchQuery, result]);
|
|
297
|
-
|
|
298
|
-
// Generate stable row IDs using primary key GUID
|
|
299
|
-
const getRowId = useCallback(
|
|
300
|
-
(item: Record<string, unknown>) => {
|
|
301
|
-
// CRITICAL: Use the GUID from the primary ID column for stable row identity
|
|
302
|
-
if (primaryIdColumn && item[primaryIdColumn]) {
|
|
303
|
-
const id = item[primaryIdColumn];
|
|
304
|
-
// Ensure it's a valid GUID string (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
|
305
|
-
if (typeof id === "string" && id.length > 0) {
|
|
306
|
-
return id;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
// Fallback: this should NOT happen if FetchXML includes the primary ID
|
|
310
|
-
// Use index as last resort (will break selection if data changes)
|
|
311
|
-
console.warn("Row without primary ID found - selection may not work correctly");
|
|
312
|
-
return `row_${Math.random()}`;
|
|
313
|
-
},
|
|
314
|
-
[primaryIdColumn]
|
|
315
|
-
);
|
|
316
|
-
|
|
317
|
-
// Build set of requested attributes from FetchXML query
|
|
318
|
-
const requestedAttributes = useMemo(() => {
|
|
319
|
-
const attrs = new Set<string>();
|
|
320
|
-
if (!fetchQuery) return attrs;
|
|
321
|
-
|
|
322
|
-
// Collect from root entity attributes
|
|
323
|
-
const collectAttributes = (attributes: Array<AttributeNode>) => {
|
|
324
|
-
attributes.forEach((attr) => {
|
|
325
|
-
// Add both the original attribute name and alias (if exists)
|
|
326
|
-
attrs.add(attr.name);
|
|
327
|
-
if (attr.alias) {
|
|
328
|
-
attrs.add(attr.alias);
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
if (fetchQuery.entity.attributes) {
|
|
334
|
-
collectAttributes(fetchQuery.entity.attributes);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Collect from link-entities recursively
|
|
338
|
-
const collectFromLinks = (links: Array<any>, linkAlias?: string) => {
|
|
339
|
-
links.forEach((link) => {
|
|
340
|
-
const currentAlias = link.alias || linkAlias;
|
|
341
|
-
if (link.attributes) {
|
|
342
|
-
link.attributes.forEach((attr: AttributeNode) => {
|
|
343
|
-
// For link-entity attributes, the column name in results may be:
|
|
344
|
-
// 1. attr.alias (if explicitly aliased)
|
|
345
|
-
// 2. linkAlias.attributeName (if link has alias)
|
|
346
|
-
// 3. entityname.attributeName (Dataverse default)
|
|
347
|
-
attrs.add(attr.name); // Base attribute name
|
|
348
|
-
if (attr.alias) {
|
|
349
|
-
attrs.add(attr.alias); // Explicit alias
|
|
350
|
-
}
|
|
351
|
-
if (currentAlias) {
|
|
352
|
-
attrs.add(`${currentAlias}.${attr.name}`); // link-entity prefixed
|
|
353
|
-
}
|
|
354
|
-
if (link.name) {
|
|
355
|
-
attrs.add(`${link.name}.${attr.name}`); // entity name prefixed
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
if (link.links) {
|
|
360
|
-
collectFromLinks(link.links, currentAlias);
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
};
|
|
364
|
-
|
|
365
|
-
if (fetchQuery.entity.links) {
|
|
366
|
-
collectFromLinks(fetchQuery.entity.links);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return attrs;
|
|
370
|
-
}, [fetchQuery]);
|
|
371
|
-
|
|
372
|
-
// Build comprehensive column metadata map from FetchXML query structure
|
|
373
|
-
// Maps column name (as it appears in results) to display info
|
|
374
|
-
interface ColumnDisplayInfo {
|
|
375
|
-
/** Original attribute logical name */
|
|
376
|
-
attributeName: string;
|
|
377
|
-
/** Entity logical name this attribute belongs to */
|
|
378
|
-
entityName: string;
|
|
379
|
-
/** Explicit alias provided in FetchXML (if any) */
|
|
380
|
-
alias?: string;
|
|
381
|
-
/** Link-entity alias (if from link-entity) */
|
|
382
|
-
linkEntityAlias?: string;
|
|
383
|
-
/** Whether this is a lookup column (N:1 navigation) */
|
|
384
|
-
isLookupColumn?: boolean;
|
|
385
|
-
/** The lookup attribute on parent entity that joins to this link-entity (for N:1 relationships) */
|
|
386
|
-
lookupAttribute?: string;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const columnDisplayMap = useMemo(() => {
|
|
390
|
-
const map = new Map<string, ColumnDisplayInfo>();
|
|
391
|
-
if (!fetchQuery?.entity?.name) return map;
|
|
392
|
-
|
|
393
|
-
const rootEntityName = fetchQuery.entity.name;
|
|
394
|
-
|
|
395
|
-
// Collect from root entity attributes
|
|
396
|
-
if (fetchQuery.entity.attributes) {
|
|
397
|
-
fetchQuery.entity.attributes.forEach((attr) => {
|
|
398
|
-
const columnKey = attr.alias || attr.name;
|
|
399
|
-
map.set(columnKey, {
|
|
400
|
-
attributeName: attr.name,
|
|
401
|
-
entityName: rootEntityName,
|
|
402
|
-
alias: attr.alias,
|
|
403
|
-
});
|
|
404
|
-
// Also map lookup column format (_attributename_value) for lookup fields
|
|
405
|
-
map.set(`_${attr.name}_value`, {
|
|
406
|
-
attributeName: attr.name,
|
|
407
|
-
entityName: rootEntityName,
|
|
408
|
-
isLookupColumn: true,
|
|
409
|
-
});
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Collect from link-entities recursively
|
|
414
|
-
const collectFromLinks = (
|
|
415
|
-
links: import("../../model/nodes").LinkEntityNode[],
|
|
416
|
-
parentLookupAttr?: string
|
|
417
|
-
) => {
|
|
418
|
-
links.forEach((link) => {
|
|
419
|
-
const linkAlias = link.alias || link.name;
|
|
420
|
-
// For N:1 relationships, 'to' is the lookup attribute on the parent entity (e.g., primarycontactid)
|
|
421
|
-
// For 1:N relationships, 'to' is the PK on the parent entity
|
|
422
|
-
// We use 'to' as it represents the attribute on the parent that links to this entity
|
|
423
|
-
const lookupAttr = link.to || parentLookupAttr;
|
|
424
|
-
|
|
425
|
-
if (link.attributes) {
|
|
426
|
-
link.attributes.forEach((attr) => {
|
|
427
|
-
// For link-entity attributes, the column in results may be:
|
|
428
|
-
// 1. attr.alias (if explicitly aliased)
|
|
429
|
-
// 2. linkAlias.attributeName (standard format)
|
|
430
|
-
const columnKey = attr.alias || `${linkAlias}.${attr.name}`;
|
|
431
|
-
map.set(columnKey, {
|
|
432
|
-
attributeName: attr.name,
|
|
433
|
-
entityName: link.name,
|
|
434
|
-
alias: attr.alias,
|
|
435
|
-
linkEntityAlias: linkAlias,
|
|
436
|
-
lookupAttribute: lookupAttr,
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
// Also map entityname.attributeName variant
|
|
440
|
-
if (link.name !== linkAlias) {
|
|
441
|
-
map.set(`${link.name}.${attr.name}`, {
|
|
442
|
-
attributeName: attr.name,
|
|
443
|
-
entityName: link.name,
|
|
444
|
-
linkEntityAlias: linkAlias,
|
|
445
|
-
lookupAttribute: lookupAttr,
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
if (link.links) {
|
|
452
|
-
collectFromLinks(link.links, link.to);
|
|
453
|
-
}
|
|
454
|
-
});
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
if (fetchQuery.entity.links) {
|
|
458
|
-
collectFromLinks(fetchQuery.entity.links);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
return map;
|
|
462
|
-
}, [fetchQuery]);
|
|
463
|
-
|
|
464
|
-
// Memoize column definitions with display names and formatted values
|
|
465
|
-
// Uses columnConfig for ordering and widths when available
|
|
466
|
-
const columns = useMemo(() => {
|
|
467
|
-
if (!result || result.columns.length === 0) return [];
|
|
468
|
-
|
|
469
|
-
// Filter out OData and CRM annotation columns
|
|
470
|
-
let displayableColumns = filterDisplayableColumns(result.columns);
|
|
471
|
-
|
|
472
|
-
// Further filter to ONLY columns requested in FetchXML (if fetchQuery is available)
|
|
473
|
-
if (fetchQuery && requestedAttributes.size > 0) {
|
|
474
|
-
displayableColumns = displayableColumns.filter((col) => {
|
|
475
|
-
// Check if this column or its base attribute (for lookups) is requested
|
|
476
|
-
// For lookup columns (_attributename_value), check the base attribute name
|
|
477
|
-
if (col.startsWith("_") && col.endsWith("_value")) {
|
|
478
|
-
const baseAttr = col.slice(1, -6); // Remove _ prefix and _value suffix
|
|
479
|
-
return requestedAttributes.has(baseAttr);
|
|
480
|
-
}
|
|
481
|
-
// For aliased or regular columns, check directly
|
|
482
|
-
return requestedAttributes.has(col);
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// If columnConfig is provided, use it to order the columns
|
|
487
|
-
if (columnConfig && columnConfig.columns.length > 0) {
|
|
488
|
-
const configOrder = columnConfig.columns.map((c) => c.name);
|
|
489
|
-
|
|
490
|
-
// Build a map that handles lookup field naming convention
|
|
491
|
-
// FetchXML/config uses: primarycontactid, but Dataverse returns: _primarycontactid_value
|
|
492
|
-
const getConfigIndex = (col: string): number => {
|
|
493
|
-
// Direct match first
|
|
494
|
-
const directIndex = configOrder.indexOf(col);
|
|
495
|
-
if (directIndex >= 0) return directIndex;
|
|
496
|
-
|
|
497
|
-
// If it's a lookup column (_xxx_value), try matching the base attribute name
|
|
498
|
-
if (col.startsWith("_") && col.endsWith("_value")) {
|
|
499
|
-
const baseAttr = col.slice(1, -6);
|
|
500
|
-
const baseIndex = configOrder.indexOf(baseAttr);
|
|
501
|
-
if (baseIndex >= 0) return baseIndex;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
return -1; // Not in config
|
|
505
|
-
};
|
|
506
|
-
|
|
507
|
-
// Sort displayable columns by config order, append any not in config at end
|
|
508
|
-
const inConfig = displayableColumns.filter((col) => getConfigIndex(col) >= 0);
|
|
509
|
-
const notInConfig = displayableColumns.filter((col) => getConfigIndex(col) < 0);
|
|
510
|
-
|
|
511
|
-
displayableColumns = [
|
|
512
|
-
...inConfig.sort((a, b) => getConfigIndex(a) - getConfigIndex(b)),
|
|
513
|
-
...notInConfig,
|
|
514
|
-
];
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return displayableColumns.flatMap((col) => {
|
|
518
|
-
// Get column display info from our comprehensive map
|
|
519
|
-
const displayInfo = columnDisplayMap.get(col);
|
|
520
|
-
|
|
521
|
-
// Helper to get attribute metadata from multi-entity map
|
|
522
|
-
const getAttributeMetadata = (
|
|
523
|
-
entityName: string,
|
|
524
|
-
attrName: string
|
|
525
|
-
): AttributeMetadata | undefined => {
|
|
526
|
-
return attributeMetadata?.get(entityName)?.get(attrName);
|
|
527
|
-
};
|
|
528
|
-
|
|
529
|
-
// Resolve attribute metadata based on display info
|
|
530
|
-
let attribute: AttributeMetadata | undefined;
|
|
531
|
-
|
|
532
|
-
if (displayInfo) {
|
|
533
|
-
attribute = getAttributeMetadata(displayInfo.entityName, displayInfo.attributeName);
|
|
534
|
-
} else if (col.startsWith("_") && col.endsWith("_value")) {
|
|
535
|
-
// Fallback for lookup columns not in our map
|
|
536
|
-
const baseAttr = col.slice(1, -6);
|
|
537
|
-
const rootEntity = fetchQuery?.entity?.name;
|
|
538
|
-
if (rootEntity) {
|
|
539
|
-
attribute = getAttributeMetadata(rootEntity, baseAttr);
|
|
540
|
-
}
|
|
541
|
-
} else {
|
|
542
|
-
// Fallback: try root entity
|
|
543
|
-
const rootEntity = fetchQuery?.entity?.name;
|
|
544
|
-
if (rootEntity) {
|
|
545
|
-
attribute = getAttributeMetadata(rootEntity, col);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Determine display name with comprehensive logic:
|
|
550
|
-
// 1. For aliased columns: Use the alias as display name
|
|
551
|
-
// 2. For lookup columns (_attr_value): Use attribute display name (e.g., "Primary Contact")
|
|
552
|
-
// 3. For link-entity columns: "{Attribute Display Name} ({Lookup Attribute Display Name})"
|
|
553
|
-
// 4. For root entity columns: Use attribute display name
|
|
554
|
-
// 5. Fallback: Clean up column name
|
|
555
|
-
// If displaySettings.useLogicalNames is true, show logical names instead
|
|
556
|
-
let displayName: string;
|
|
557
|
-
|
|
558
|
-
// Helper to get logical name (column key or attribute LogicalName)
|
|
559
|
-
const getLogicalName = (): string => {
|
|
560
|
-
if (displayInfo?.isLookupColumn) {
|
|
561
|
-
return displayInfo.attributeName || col.slice(1, -6);
|
|
562
|
-
}
|
|
563
|
-
if (displayInfo?.linkEntityAlias) {
|
|
564
|
-
return `${displayInfo.linkEntityAlias}.${displayInfo.attributeName || col}`;
|
|
565
|
-
}
|
|
566
|
-
return displayInfo?.attributeName || col;
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
if (displaySettings?.useLogicalNames) {
|
|
570
|
-
// Show logical name
|
|
571
|
-
displayName = getLogicalName();
|
|
572
|
-
} else if (displayInfo?.alias) {
|
|
573
|
-
// User-specified alias takes precedence
|
|
574
|
-
displayName = displayInfo.alias;
|
|
575
|
-
} else if (displayInfo?.isLookupColumn) {
|
|
576
|
-
// Lookup column: show attribute display name (represents the lookup field)
|
|
577
|
-
displayName =
|
|
578
|
-
attribute?.DisplayName?.UserLocalizedLabel?.Label ||
|
|
579
|
-
displayInfo.attributeName ||
|
|
580
|
-
col.slice(1, -6); // Remove _ prefix and _value suffix
|
|
581
|
-
} else if (displayInfo?.linkEntityAlias) {
|
|
582
|
-
// Link-entity column: "Attribute Display Name (Lookup Attribute Display Name)"
|
|
583
|
-
// e.g., "Email (Primary Contact)" where Email is from contact entity, Primary Contact is the lookup
|
|
584
|
-
const attrDisplayName =
|
|
585
|
-
attribute?.DisplayName?.UserLocalizedLabel?.Label || displayInfo.attributeName;
|
|
586
|
-
|
|
587
|
-
// Get the lookup attribute display name from root entity metadata
|
|
588
|
-
let lookupDisplayName = displayInfo.lookupAttribute || displayInfo.linkEntityAlias;
|
|
589
|
-
if (displayInfo.lookupAttribute) {
|
|
590
|
-
const rootEntityName = fetchQuery?.entity?.name;
|
|
591
|
-
if (rootEntityName) {
|
|
592
|
-
const lookupAttr = getAttributeMetadata(rootEntityName, displayInfo.lookupAttribute);
|
|
593
|
-
if (lookupAttr?.DisplayName?.UserLocalizedLabel?.Label) {
|
|
594
|
-
lookupDisplayName = lookupAttr.DisplayName.UserLocalizedLabel.Label;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
displayName = `${attrDisplayName} (${lookupDisplayName})`;
|
|
600
|
-
} else if (attribute?.DisplayName?.UserLocalizedLabel?.Label) {
|
|
601
|
-
// Root entity attribute with metadata display name
|
|
602
|
-
displayName = attribute.DisplayName.UserLocalizedLabel.Label;
|
|
603
|
-
} else if (col.startsWith("_") && col.endsWith("_value")) {
|
|
604
|
-
// Fallback for lookup columns without metadata
|
|
605
|
-
displayName = col.slice(1, -6);
|
|
606
|
-
} else {
|
|
607
|
-
// Final fallback: use column name as-is
|
|
608
|
-
displayName = col;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// Check if this column is sorted (from FetchXML orders)
|
|
612
|
-
const sortInfo = sortStateMap.get(col);
|
|
613
|
-
const valueMode = displaySettings?.valueDisplayMode ?? "formatted";
|
|
614
|
-
|
|
615
|
-
// For "both" mode, check if this column has formatted values in the result set
|
|
616
|
-
// Only create a raw column if at least one record has a different formatted value
|
|
617
|
-
const hasFormattedValues =
|
|
618
|
-
valueMode === "both" &&
|
|
619
|
-
result?.rows?.some((record: Record<string, unknown>) => {
|
|
620
|
-
const rawValue = record[col];
|
|
621
|
-
const formattedValue = getFormattedValue(record, col);
|
|
622
|
-
return formattedValue !== undefined && formattedValue !== rawValue;
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
// For "both" mode with formatted values, create two columns
|
|
626
|
-
if (valueMode === "both" && hasFormattedValues) {
|
|
627
|
-
// Create formatted value column
|
|
628
|
-
const formattedColumn = createTableColumn<Record<string, unknown>>({
|
|
629
|
-
columnId: col,
|
|
630
|
-
compare: (a, b) => {
|
|
631
|
-
const aVal = String(getFormattedValue(a, col) ?? a[col] ?? "");
|
|
632
|
-
const bVal = String(getFormattedValue(b, col) ?? b[col] ?? "");
|
|
633
|
-
return aVal.localeCompare(bVal);
|
|
634
|
-
},
|
|
635
|
-
renderHeaderCell: () => (
|
|
636
|
-
<span className={styles.headerCellContent}>
|
|
637
|
-
<span style={{ fontWeight: 600 }}>{displayName}</span>
|
|
638
|
-
{/* Show sort indicator based on FetchXML order state */}
|
|
639
|
-
{sortInfo && (
|
|
640
|
-
<span className={styles.sortIndicator}>
|
|
641
|
-
{sortInfo.direction === "ascending" ? (
|
|
642
|
-
<ArrowSortUp16Regular />
|
|
643
|
-
) : (
|
|
644
|
-
<ArrowSortDown16Regular />
|
|
645
|
-
)}
|
|
646
|
-
</span>
|
|
647
|
-
)}
|
|
648
|
-
</span>
|
|
649
|
-
),
|
|
650
|
-
renderCell: (item) => {
|
|
651
|
-
const rawValue = item[col];
|
|
652
|
-
const formattedValue = getFormattedValue(item, col);
|
|
653
|
-
return (
|
|
654
|
-
<TableCellLayout>
|
|
655
|
-
{getCellRenderer(attribute?.AttributeType, rawValue, formattedValue, attribute)}
|
|
656
|
-
</TableCellLayout>
|
|
657
|
-
);
|
|
658
|
-
},
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
// Create raw value column
|
|
662
|
-
const rawColumn = createTableColumn<Record<string, unknown>>({
|
|
663
|
-
columnId: `${col}__raw`,
|
|
664
|
-
compare: (a, b) => {
|
|
665
|
-
const aVal = String(a[col] ?? "");
|
|
666
|
-
const bVal = String(b[col] ?? "");
|
|
667
|
-
return aVal.localeCompare(bVal);
|
|
668
|
-
},
|
|
669
|
-
renderHeaderCell: () => (
|
|
670
|
-
<span className={styles.headerCellContent}>
|
|
671
|
-
<span style={{ fontWeight: 600 }}>{displayName} (Raw)</span>
|
|
672
|
-
</span>
|
|
673
|
-
),
|
|
674
|
-
renderCell: (item) => {
|
|
675
|
-
const rawValue = item[col];
|
|
676
|
-
return (
|
|
677
|
-
<TableCellLayout>
|
|
678
|
-
{rawValue === null || rawValue === undefined ? (
|
|
679
|
-
<span>—</span>
|
|
680
|
-
) : (
|
|
681
|
-
<span>{String(rawValue)}</span>
|
|
682
|
-
)}
|
|
683
|
-
</TableCellLayout>
|
|
684
|
-
);
|
|
685
|
-
},
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
return [formattedColumn, rawColumn];
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// Standard single column (formatted or raw mode)
|
|
692
|
-
return createTableColumn<Record<string, unknown>>({
|
|
693
|
-
columnId: col,
|
|
694
|
-
compare: (a, b) => {
|
|
695
|
-
const aVal = String(getFormattedValue(a, col) ?? a[col] ?? "");
|
|
696
|
-
const bVal = String(getFormattedValue(b, col) ?? b[col] ?? "");
|
|
697
|
-
return aVal.localeCompare(bVal);
|
|
698
|
-
},
|
|
699
|
-
renderHeaderCell: () => (
|
|
700
|
-
<span className={styles.headerCellContent}>
|
|
701
|
-
<span style={{ fontWeight: 600 }}>{displayName}</span>
|
|
702
|
-
{/* Show sort indicator based on FetchXML order state */}
|
|
703
|
-
{sortInfo && (
|
|
704
|
-
<span className={styles.sortIndicator}>
|
|
705
|
-
{sortInfo.direction === "ascending" ? (
|
|
706
|
-
<ArrowSortUp16Regular />
|
|
707
|
-
) : (
|
|
708
|
-
<ArrowSortDown16Regular />
|
|
709
|
-
)}
|
|
710
|
-
</span>
|
|
711
|
-
)}
|
|
712
|
-
</span>
|
|
713
|
-
),
|
|
714
|
-
renderCell: (item) => {
|
|
715
|
-
const rawValue = item[col];
|
|
716
|
-
const formattedValue = getFormattedValue(item, col);
|
|
717
|
-
|
|
718
|
-
// Determine what to display based on value display mode
|
|
719
|
-
if (valueMode === "raw") {
|
|
720
|
-
// Show raw value only (skip cell renderer formatting)
|
|
721
|
-
return (
|
|
722
|
-
<TableCellLayout>
|
|
723
|
-
{rawValue === null || rawValue === undefined ? (
|
|
724
|
-
<span>—</span>
|
|
725
|
-
) : (
|
|
726
|
-
<span>{String(rawValue)}</span>
|
|
727
|
-
)}
|
|
728
|
-
</TableCellLayout>
|
|
729
|
-
);
|
|
730
|
-
} else {
|
|
731
|
-
// Default: formatted (use cell renderer)
|
|
732
|
-
return (
|
|
733
|
-
<TableCellLayout>
|
|
734
|
-
{getCellRenderer(attribute?.AttributeType, rawValue, formattedValue, attribute)}
|
|
735
|
-
</TableCellLayout>
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
},
|
|
739
|
-
});
|
|
740
|
-
});
|
|
741
|
-
}, [
|
|
742
|
-
result,
|
|
743
|
-
attributeMetadata,
|
|
744
|
-
columnDisplayMap,
|
|
745
|
-
fetchQuery,
|
|
746
|
-
requestedAttributes,
|
|
747
|
-
columnConfig,
|
|
748
|
-
styles,
|
|
749
|
-
sortStateMap,
|
|
750
|
-
displaySettings,
|
|
751
|
-
]);
|
|
752
|
-
|
|
753
|
-
// Selection handlers
|
|
754
|
-
const handleSelectionChange = useCallback(
|
|
755
|
-
(_e: unknown, data: { selectedItems: Set<string | number> }) => {
|
|
756
|
-
setSelectedItems(data.selectedItems);
|
|
757
|
-
onSelectedCountChange?.(data.selectedItems.size);
|
|
758
|
-
// Convert Set to array of string IDs for the callback
|
|
759
|
-
const recordIds = Array.from(data.selectedItems).map(String);
|
|
760
|
-
onSelectionChange?.(recordIds);
|
|
761
|
-
},
|
|
762
|
-
[onSelectedCountChange, onSelectionChange]
|
|
763
|
-
);
|
|
764
|
-
|
|
765
|
-
// Sort change handler: notifies parent to update FetchXML orders
|
|
766
|
-
// Shift+click adds/toggles column in multi-sort, regular click replaces all
|
|
767
|
-
// We calculate direction ourselves based on FetchXML state, not Fluent's sortDirection
|
|
768
|
-
const handleSortChange = useCallback(
|
|
769
|
-
(
|
|
770
|
-
e: React.MouseEvent,
|
|
771
|
-
data: { sortColumn: string | number | undefined; sortDirection: SortDirection }
|
|
772
|
-
) => {
|
|
773
|
-
if (!onSortChange) return;
|
|
774
|
-
|
|
775
|
-
const columnId = String(data.sortColumn);
|
|
776
|
-
const isMultiSort = e.shiftKey;
|
|
777
|
-
|
|
778
|
-
// Get current sort info for this column from FetchXML state
|
|
779
|
-
const sortInfo = sortStateMap.get(columnId);
|
|
780
|
-
|
|
781
|
-
// Calculate direction: toggle if already sorted, otherwise ascending
|
|
782
|
-
let direction: SortDirection;
|
|
783
|
-
if (sortInfo) {
|
|
784
|
-
// Column is already sorted - toggle direction
|
|
785
|
-
direction = sortInfo.direction === "ascending" ? "descending" : "ascending";
|
|
786
|
-
} else {
|
|
787
|
-
// Column not sorted yet - start with ascending
|
|
788
|
-
direction = "ascending";
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
onSortChange({
|
|
792
|
-
columnId,
|
|
793
|
-
direction,
|
|
794
|
-
isMultiSort,
|
|
795
|
-
entityName: sortInfo?.entityName,
|
|
796
|
-
});
|
|
797
|
-
},
|
|
798
|
-
[onSortChange, sortStateMap]
|
|
799
|
-
);
|
|
800
|
-
|
|
801
|
-
// Row renderer function for virtualization with scrolling indicator support
|
|
802
|
-
const renderRow: RowRenderer<Record<string, unknown>> = useCallback(
|
|
803
|
-
({ item, rowId }, style, _index, isScrolling) => (
|
|
804
|
-
<DataGridRow<Record<string, unknown>> key={rowId} style={style} className={styles.row}>
|
|
805
|
-
{({ renderCell }) => (
|
|
806
|
-
<DataGridCell focusMode="group">
|
|
807
|
-
{isScrolling ? (
|
|
808
|
-
<Skeleton style={{ width: "100%" }}>
|
|
809
|
-
<SkeletonItem shape="rectangle" animation="pulse" appearance="translucent" />
|
|
810
|
-
</Skeleton>
|
|
811
|
-
) : (
|
|
812
|
-
renderCell(item)
|
|
813
|
-
)}
|
|
814
|
-
</DataGridCell>
|
|
815
|
-
)}
|
|
816
|
-
</DataGridRow>
|
|
817
|
-
),
|
|
818
|
-
[styles.row]
|
|
819
|
-
);
|
|
820
|
-
|
|
821
|
-
// Build column sizing options from columnConfig or defaults
|
|
822
|
-
const columnSizingOptions = useMemo(() => {
|
|
823
|
-
const options: Record<string, { minWidth: number; defaultWidth: number; idealWidth?: number }> =
|
|
824
|
-
{};
|
|
825
|
-
|
|
826
|
-
// Create a map of column widths from config
|
|
827
|
-
// Also map lookup field variants (_xxx_value -> config width for xxx)
|
|
828
|
-
const configWidths = new Map<string, number>();
|
|
829
|
-
if (columnConfig) {
|
|
830
|
-
for (const col of columnConfig.columns) {
|
|
831
|
-
configWidths.set(col.name, col.width);
|
|
832
|
-
// Also map the lookup column variant
|
|
833
|
-
configWidths.set(`_${col.name}_value`, col.width);
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// Set sizing for each column
|
|
838
|
-
for (const col of columns) {
|
|
839
|
-
const columnId = String(col.columnId);
|
|
840
|
-
const configWidth = configWidths.get(columnId);
|
|
841
|
-
const width = configWidth ?? 150; // Default to 150 if not in config
|
|
842
|
-
options[columnId] = {
|
|
843
|
-
minWidth: 80,
|
|
844
|
-
defaultWidth: width,
|
|
845
|
-
idealWidth: width,
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
return options;
|
|
850
|
-
}, [columns, columnConfig]);
|
|
851
|
-
|
|
852
|
-
// Handle column resize callback
|
|
853
|
-
// Maps lookup column names back to config names for storage
|
|
854
|
-
const handleColumnResize = useCallback(
|
|
855
|
-
(
|
|
856
|
-
_e: KeyboardEvent | TouchEvent | MouseEvent | undefined,
|
|
857
|
-
data: { columnId: string | number; width: number }
|
|
858
|
-
) => {
|
|
859
|
-
if (onColumnResize) {
|
|
860
|
-
let columnName = String(data.columnId);
|
|
861
|
-
// If it's a lookup column (_xxx_value), store width under the base attribute name
|
|
862
|
-
if (columnName.startsWith("_") && columnName.endsWith("_value")) {
|
|
863
|
-
columnName = columnName.slice(1, -6);
|
|
864
|
-
}
|
|
865
|
-
onColumnResize(columnName, data.width);
|
|
866
|
-
}
|
|
867
|
-
},
|
|
868
|
-
[onColumnResize]
|
|
869
|
-
);
|
|
870
|
-
|
|
871
|
-
// Infinite scroll: load more when user scrolls near bottom
|
|
872
|
-
const handleItemsRendered = useCallback(
|
|
873
|
-
({ visibleStopIndex }: { visibleStartIndex: number; visibleStopIndex: number }) => {
|
|
874
|
-
if (!result || !onLoadMore || isLoadingMore) return;
|
|
875
|
-
|
|
876
|
-
// If user has scrolled to within 10 rows of the end, and there are more records
|
|
877
|
-
const rowCount = result.rows.length;
|
|
878
|
-
const threshold = Math.min(10, Math.max(1, Math.floor(rowCount * 0.1))); // 10 rows or 10% of data, whichever is smaller
|
|
879
|
-
|
|
880
|
-
if (visibleStopIndex >= rowCount - threshold && result.moreRecords) {
|
|
881
|
-
console.log("[ResultsGrid] Near bottom, triggering load more:", {
|
|
882
|
-
visibleStopIndex,
|
|
883
|
-
rowCount,
|
|
884
|
-
threshold,
|
|
885
|
-
moreRecords: result.moreRecords,
|
|
886
|
-
});
|
|
887
|
-
onLoadMore();
|
|
888
|
-
}
|
|
889
|
-
},
|
|
890
|
-
[result, onLoadMore, isLoadingMore]
|
|
891
|
-
);
|
|
892
|
-
|
|
893
|
-
// Note: Data is already sorted by Dataverse based on FetchXML orders
|
|
894
|
-
// No local sorting needed - we display rows as returned
|
|
895
|
-
|
|
896
|
-
if (isLoading) {
|
|
897
|
-
return <div className={styles.emptyState}>Loading results...</div>;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
if (!result) {
|
|
901
|
-
return <div className={styles.emptyState}>Click Execute to run the query</div>;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
if (result.rows.length === 0) {
|
|
905
|
-
return <div className={styles.emptyState}>No records found</div>;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
console.log("[ResultsGrid] Rendering grid:", {
|
|
909
|
-
rowCount: result.rows.length,
|
|
910
|
-
columnCount: columns.length,
|
|
911
|
-
gridDimensions,
|
|
912
|
-
primaryIdColumn,
|
|
913
|
-
firstRowId: result.rows.length > 0 ? getRowId(result.rows[0]) : "none",
|
|
914
|
-
columnIds: columns.slice(0, 5).map((c) => c.columnId),
|
|
915
|
-
sortStateMapSize: sortStateMap.size,
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
return (
|
|
919
|
-
<div className={styles.container}>
|
|
920
|
-
<div className={styles.gridWrapper}>
|
|
921
|
-
<div ref={containerRef} className={styles.gridContent}>
|
|
922
|
-
<DataGrid
|
|
923
|
-
aria-label={result?.entityLogicalName ?? "Results"}
|
|
924
|
-
items={result.rows}
|
|
925
|
-
columns={columns}
|
|
926
|
-
sortable
|
|
927
|
-
// Don't set sortState - we manage sort indicators ourselves in renderHeaderCell
|
|
928
|
-
// This avoids double arrows (Fluent's + ours) and gives us control over multi-sort display
|
|
929
|
-
resizableColumns
|
|
930
|
-
resizableColumnsOptions={{ autoFitColumns: false }}
|
|
931
|
-
columnSizingOptions={columnSizingOptions}
|
|
932
|
-
onColumnResize={handleColumnResize}
|
|
933
|
-
onSortChange={handleSortChange}
|
|
934
|
-
selectionMode="multiselect"
|
|
935
|
-
selectedItems={selectedItems}
|
|
936
|
-
onSelectionChange={handleSelectionChange}
|
|
937
|
-
getRowId={getRowId}
|
|
938
|
-
>
|
|
939
|
-
<DataGridHeader ref={headerRef as any} style={{ paddingRight: scrollbarWidth }}>
|
|
940
|
-
<DataGridRow style={{ minHeight: "43px", maxHeight: "43px" }}>
|
|
941
|
-
{({ renderHeaderCell }) => (
|
|
942
|
-
<DataGridHeaderCell className={styles.headerCell}>
|
|
943
|
-
{renderHeaderCell()}
|
|
944
|
-
</DataGridHeaderCell>
|
|
945
|
-
)}
|
|
946
|
-
</DataGridRow>
|
|
947
|
-
</DataGridHeader>
|
|
948
|
-
<DataGridBody<Record<string, unknown>>
|
|
949
|
-
className={styles.body}
|
|
950
|
-
itemSize={ROW_HEIGHT}
|
|
951
|
-
height={Math.max(0, gridDimensions.height - headerHeight)}
|
|
952
|
-
listProps={{
|
|
953
|
-
useIsScrolling: true,
|
|
954
|
-
className: styles.body,
|
|
955
|
-
onItemsRendered: handleItemsRendered,
|
|
956
|
-
}}
|
|
957
|
-
>
|
|
958
|
-
{renderRow}
|
|
959
|
-
</DataGridBody>
|
|
960
|
-
</DataGrid>
|
|
961
|
-
</div>
|
|
962
|
-
<div className={styles.infoBar}>
|
|
963
|
-
<span>
|
|
964
|
-
Rows: {result.rows.length}
|
|
965
|
-
{result.moreRecords && !isLoadingMore && " (more available)"}
|
|
966
|
-
{isLoadingMore && " (loading more...)"}
|
|
967
|
-
{result.executionTimeMs !== undefined && ` | Executed in ${result.executionTimeMs}ms`}
|
|
968
|
-
</span>
|
|
969
|
-
<span>
|
|
970
|
-
Selected: {selectedItems.size}
|
|
971
|
-
{selectedItems.size > 0 &&
|
|
972
|
-
selectedItems.size === result.rows.length &&
|
|
973
|
-
result.moreRecords && (
|
|
974
|
-
<span className={styles.selectionWarning}>
|
|
975
|
-
⚠ Selection limited to loaded records. Use "Retrieve all pages" to load more.
|
|
976
|
-
</span>
|
|
977
|
-
)}
|
|
978
|
-
</span>
|
|
979
|
-
</div>
|
|
980
|
-
</div>
|
|
981
|
-
</div>
|
|
982
|
-
);
|
|
983
|
-
}
|